diff --git a/.agents/skills/redeploy/SKILL.md b/.agents/skills/redeploy/SKILL.md index 1bc0fdc8c74..1bb168ac751 100644 --- a/.agents/skills/redeploy/SKILL.md +++ b/.agents/skills/redeploy/SKILL.md @@ -20,7 +20,7 @@ Redeploys the self-hosted T3 Code server to the current `origin/main`. The logic ## CRITICAL — this session will drop -This chat runs *inside* `t3code.service`. When the service restarts, **this chat's own +This chat runs _inside_ `t3code.service`. When the service restarts, **this chat's own session disconnects.** That is expected and unavoidable — the deploy still finishes in the background. Reconnect and read the status log. diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index c3dc1c103d4..45524f6fcd3 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -1,6 +1,6 @@ --- name: test-t3-app -description: Launch and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. +description: Launch, retain, and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, cross-turn dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, iteratively test UI behavior with a human, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. --- # Test T3 App @@ -13,13 +13,35 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi 2. Choose a base directory that belongs only to the current worktree or test: - Use the repository's ignored `.t3` directory for reusable worktree-local state. - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. -3. Start the full web stack with `vp run dev --home-dir `. +3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir ` only when the test needs a different isolated directory. 4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. +The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server. + +Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line. + +Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. + The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. +### Verify a shared environment before human handoff + +When another person will use the printed pairing URL, first open the shared origin without the pairing path or fragment in the controlled browser and confirm the T3 Code app loads. This browser navigation is required even when curl succeeds because browsers block some otherwise reachable ports before making a network request. + +Do not open the other person's complete pairing URL during this reachability check; doing so consumes its one-time token. If the agent also needs an authenticated browser, create and consume a separate pairing token, then leave a fresh token for the other person. + +## Preserve the environment while iterating + +Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary. + +- Keep the dev process, base directory, selected ports, authenticated browser tab, registered projects, and seeded fixtures alive while the user may inspect the result or request follow-up changes. +- Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. +- Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. +- On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. + ## Authenticate the browser on the first navigation 1. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`. @@ -45,7 +67,7 @@ T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. ## Inspect or seed SQLite state @@ -58,9 +80,17 @@ Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before chang The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation. -## Finish the test +## Tear down only when the testing loop is finished + +Tear down when the user explicitly asks, confirms the iteration is finished, or the overall task is genuinely complete with no pending human review. Do not infer completion from the end of an assistant turn. + +When teardown is appropriate: + +1. Stop the dev process with its terminal interrupt. +2. Preserve the isolated base directory when it contains useful reproduction evidence or state for a likely follow-up. +3. Otherwise remove only a path created for this test after resolving and verifying the exact target. -Stop the dev process with its terminal interrupt. Preserve the isolated base directory when it contains useful reproduction evidence; otherwise remove only a path that was created for this test after resolving and verifying the exact target. A fresh isolated base directory is the safest reset when authentication, migrations, or fixture state becomes ambiguous. +If completion is uncertain, keep the environment alive and mention that it is retained for further iteration. A fresh isolated base directory remains the safest reset when authentication, migrations, or fixture state becomes ambiguous. ## Troubleshoot predictably diff --git a/.agents/skills/test-t3-app/agents/openai.yaml b/.agents/skills/test-t3-app/agents/openai.yaml index a3b89c95e60..0445ee4cb9e 100644 --- a/.agents/skills/test-t3-app/agents/openai.yaml +++ b/.agents/skills/test-t3-app/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "T3 App Testing" - short_description: "Launch and seed isolated T3 test environments" - default_prompt: "Use $test-t3-app to launch an isolated T3 development environment and test it in the browser." + short_description: "Launch and retain isolated T3 test environments" + default_prompt: "Use $test-t3-app to launch an isolated T3 environment and iteratively test it in the browser while preserving state." diff --git a/AGENTS.md b/AGENTS.md index 965bb614567..e671c369928 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,14 @@ If a tradeoff is required, choose correctness and robustness over short-term con Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. +## Dev Servers + +- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. +- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. +- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. +- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. +- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports. + ## Package Roles - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. @@ -86,6 +94,31 @@ sessions T3 itself spawned are skipped (`skipped-owned` — session id found in worktrees dir; `skipped-copy` — a forkSession copy whose message uuids largely already live on another thread), and deleted imported threads stay deleted via the event-log tombstone. +## Upstream sync PRs: NEVER squash-merge + +Sync PRs (`merge: upstream through `) must land as a **merge commit** or a +fast-forward push (`git push origin HEAD:refs/heads/main`). Never squash-merge, +never rebase-merge, never `git revert -m 1`. + +Squash-merging discards the second parent, so git stops believing upstream is an +ancestor. PR #42 was squash-merged and the next sync's conflict set went from +18 files / 40 hunks to **76 files / 238 hunks** — a 4x tax paid for one wrong +click, plus an inflated 208-commit delta when only 79 commits were actually new. +Rebase-merge cannot replay merge commits at all. `git revert -m 1` is the same +class of trap: ancestry keeps claiming upstream is merged while the content is +gone, so a later `git merge upstream/main` never restores the reverted files. + +If it happens anyway, it is repairable while the PR branch still exists on +GitHub (the repo has `delete_branch_on_merge:false`): re-merge that branch with +`git merge --no-ff origin/t3code/`. The tree is unchanged and the +upstream parent is restored. + +After landing a sync, assert the repair held: + +``` +git merge-base origin/main upstream/main # must be the SHA you merged, not an older one +``` + ## Finishing a feature: MANDATORY test-deploy + PR flow When a feature or bugfix is complete in a `t3code/*` worktree, do NOT merge to `main`. Follow **[docs/test-deployments.md](docs/test-deployments.md)**: open a PR, deploy the branch to a test port from the pool (`node scripts/test-deploy.ts --pr --note "" --comment`), and comment the test URL on the PR so the user can jump straight into the running instance. Prod (external `7443` / loopback `3773` / unit `t3code.service`) is only redeployed from `main` after the user approves the PR — and `t3code.service` is never restarted without explicit user approval in the conversation. The scripts refuse all prod targets; never work around the guard. diff --git a/README.md b/README.md index fe1228b3eed..9f4c43f406b 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d ## Documentation - [Getting started](./docs/getting-started/quick-start.md) +- [Remote access](./docs/user/remote-access.md) +- [Keeping T3 Code in sync](./docs/user/server-updates.md) - [Architecture overview](./docs/architecture/overview.md) - [Provider guides](./docs/providers/codex.md) - [Operations](./docs/operations/ci.md) diff --git a/UPSTREAM_DIVERGENCE.md b/UPSTREAM_DIVERGENCE.md index 26f42117b40..fbe61ddab75 100644 --- a/UPSTREAM_DIVERGENCE.md +++ b/UPSTREAM_DIVERGENCE.md @@ -82,6 +82,179 @@ sync base `dad0889` (2026-07-07). --- +## 2026-07-27 — True merge of upstream through `f4c394323` (2026-07-26) + +Sidebar v2 beta, the glass redesign, "Auto" runtime mode, thread snoozing, +shared `t3.json` project config, remote server updates. **79 commits** new since +the `6f34ad3e8` cut point (git reported 208 — see the ancestry note below). +18 files conflicted; 38 more were modified on both sides and auto-merged. + +### Ancestry repair (do this before any future sync) + +PR #42 was **squash-merged**, which discards the second parent. `origin/main` +carried the merged content of the 2026-07-23 sync but git no longer believed +`6f34ad3e8` was an ancestor, so `merge-base` resolved back to `dad088976` +(2026-07-07). Measured cost: **76 conflicted files / 238 hunks / 10 add-add** +instead of 18 / 40 / 0. + +Repaired by re-merging PR #42's original merge commit, which still existed on +GitHub as `origin/t3code/upstream-sidebar-change` (the repo keeps merged +branches). The tree was byte-identical, so the graft commit has an empty diff +and only restores provenance. A rule was added to `AGENTS.md`: sync PRs land as +merge commits or ff-push, never squash, never rebase, never `git revert -m 1`. + +Note: `upstream/main` advanced from `89c5a192f` to `f4c394323` between planning +and merging, so this sync includes one commit beyond the planned cut point +(`f4c394323`, background preview capture / picture-in-picture). + +### Migration numbering — permanent fork divergence + +Upstream shipped `033_ProjectionThreadsSettled` and `034_ProjectionThreadsSnoozed`. +This fork already owns id 33 (`ProjectionThreadRestartRequest`), **recorded in +production's `effect_sql_migrations` since 2026-07-09**. Effect's Migrator run +loop is purely ordinal — `if (currentId <= latestMigrationId) continue`, with no +name or checksum comparison — so renumbering the fork's migration upward would +mean upstream's id-33 never runs on prod, `settled_override`/`settled_at` are +never created, and the (silently auto-merged) `ProjectionThreads.ts` queries +that name those columns fail on every thread read and write. **A full outage +that CI and every fresh-DB test-deploy pass green.** + +Resolution: the fork keeps 33; **upstream's two are renumbered locally to 034 +and 035**. Prod's high-water mark of 33 then simply admits them on next boot, +with zero database surgery. Every future sync must re-apply this offset +(currently +1, growing by one per fork-only migration added). + +**Known hole while the offset stands:** a `state.sqlite` created by a stock +`npx t3` release (e.g. the ARM Pi) records 33 = `ProjectionThreadsSettled`, so a +fork build opening such a database would skip the fork's restart-request +migration by number and fail every thread read on `requesting_restart`. **Fork +builds must not be pointed at stock-created databases.** Closed permanently by a +planned `ensureForkSchema` seam — the first follow-up PR, never bundled with a +merge. + +### Taken from upstream, replacing fork implementations + +- **Claude Opus 5.** Fork PR #44 is retired for upstream's `41a430a88`, a strict + superset: a 200k/1M `contextWindow` descriptor (so the API id is really + `claude-opus-5[1m]`) and a dedicated `MINIMUM_CLAUDE_OPUS_5_VERSION = 2.1.219` + gate. The fork reused Fable 5's 2.1.169 gate and advertised 1M while sending + 200k. Removed with it: the `case "claude-opus-5"` short-circuit in + `selectedClaudeContextWindow` (`ClaudeAdapter.ts`), which would have silently + overridden upstream's descriptor — **that file auto-merged with no conflict**. + Also removed: the fork's Opus-5 assertions inside the Fable-5 registry tests, + which asserted presence at CLI 2.1.169 and would now fail. One live prod thread + on `claude-opus-5` with no persisted `contextWindow` moves from 200k to 1M — + what the UI already claimed, but a real cost change. +- **`connectionStatusHeadline`** retired for upstream's `connectionStatusTitle` + (`315b27385`), which delegates to `connectionStatusText` and stays in sync with + the phase table automatically. Same pattern as the #40 shell-sync retirement. +- **`ui/alert.tsx` / `ComposerBannerStack`**: the fork's flex-wrap refactor + reverted. Upstream's banner stack handles narrow screens with grid utilities + that assume upstream markup. + +### Deliberately kept against upstream + +- **`showsHorizontalScrollIndicator={false}`** on `MessagesTimeline`. LegendList + otherwise emits an inline `overflowX: auto` that overrides `overflow-x-hidden` + and lets a stray horizontal axis hijack left-edge text selection. No test + covers this; it will re-conflict. +- **No eager `threadModelSelections` write at turn start** in + `ProviderCommandReactor`. Upstream added one alongside `pendingTurnStart`; the + fork's deferred model-change recycle decides whether a pending recycle is still + needed by comparing against that map, so writing it eagerly makes every pending + recycle look already applied. Taking upstream's side broke 7 deferred-recycle + tests. Upstream's `pendingTurnStart: true` flag is kept. +- **`importCommand`** in `bin.ts` alongside upstream's new `serviceCommand`. + +### Sidebar — fork work culled by owner decision + +Only the host CPU/memory readout survives. It moved out of `Sidebar.tsx` into +the shared `components/sidebar/SidebarChrome.tsx`, so it now renders in **both** +sidebar v1 and the v2 beta (previously v1 only). Upstream had extracted +`SidebarChromeHeader`/`Footer` into that file with no host-stats slot, so taking +their side of the hunk would have deleted the mount and stranded the imports. + +Dropped: `sidebarChatListView` (the grouped/flat toggle), boxed project cards, +the restart-request sidebar pill and its test. **The restart-request feature +itself is untouched** — server detection, chat surfacing and migration 033 all +remain; only its sidebar affordance is gone. + +Sidebar v2 ships **default OFF** (upstream's own default). + +### Color schemes culled to Solarized + +Upstream's glass redesign introduced a token family (`--sidebar-*`, `--glass-*`, +`--chat-composer-*`, `--surface-raised`) that every scheme must re-declare. +Maintaining five against an upstream that keeps hard-coding palettes into new +surfaces was not worth it. Dracula, Gruvbox, Catppuccin and Tokyo Night are +removed; a stored value for one of them falls back to `default`. + +**Upstream's `[data-sidebar-version]` block had its ten generic tokens stripped** +(`--background`, `--foreground`, `--card`, `--card-foreground`, `--accent`, +`--accent-foreground`, `--muted`, `--muted-foreground`, `--border`, `--input`). +That block applies unconditionally — `AppSidebarLayout` sets the attribute even +with the beta off — and element-scoped custom properties beat every +`:root[data-scheme]` declaration regardless of specificity, so keeping them would +kill Solarized inside the entire sidebar subtree, including the host-stats +readout. Only the `--sidebar-*` family and `background-color` are retained, and +Solarized now declares that family itself. **Expect this to re-conflict on every +upstream sidebar-styling sync.** + +Resolving this hunk by naive union does not build: both sides are `+1` brace +depth and share one closing `}` past the marker. This is the second time that +trap has appeared in `index.css` — verify with a brace-balance check and a real +`@t3tools/web build`, not just a typecheck. + +### Merged but gated or must-not-run + +- **"Auto" runtime mode** (`fbd77420f`) is merged but **hidden behind a Features + per-device toggle** (`composerAutoRuntimeModeVisible`, default off). It hands + tool-call approval to an AI reviewer inside a workspace-write sandbox. It is + also a one-way door for rollback: `ProjectionThread.runtimeMode` has no + decoding default and `listThreadRows` is a `findAll`, so one thread set to + Auto makes the whole shell snapshot undecodable on a pre-merge build. See + `docs/operations/rollback-2026-07-27-sync.md`. +- **`t3 service install` / `uninstall`** (`ab4a88386`) is merged but **must never + be run on the deploy host**. `bootService.ts` hard-codes the unit name + `t3code.service` and unconditionally writes/deletes + `~/.config/systemd/user/t3code.service` — the hand-written production unit. A + reference copy is now committed at `deploy/t3code.service`. +- **Server self-update** (`ab4a88386`) merged and **audited as inert here**: + `resolveServerSelfUpdateCapability` returns `boot-service` only when + `T3_BOOT_SERVICE_UNIT === "t3code.service"` (the prod unit does not set it) and + returns null outright whenever `INVOCATION_ID` is set (it is, under systemd). + The respawn path additionally needs a published `node_modules/t3/dist/`; prod + runs a source checkout. The UI action never renders. Re-audit if the fork ever + adopts upstream's boot service. + +### Verified clean, no action needed + +State-directory resolution is **unchanged** for a production `t3 serve`: +`deriveServerPaths` and `resolveBaseDir` are byte-identical to the cut point, and +`a17cbc3b4`'s worktree-local `.t3` default is confined to `scripts/dev-runner.ts` +(nothing under `apps/` imports it). No `.github/` changes since the cut; no Node +or pnpm engine bump; packaging churn is Electron-only; the new blocked-port list +contains nothing in 7444-7453 or 3773. + +`apps/mobile/**` taken wholesale per the standing no-mobile policy — zero +conflicts. The one fork-only line preserved is the +`withAndroidSelfSignedServerTrust.cjs` plugin entry in `app.config.ts` +(`withAndroidGradleHeap.cjs` is now upstream's own), along with +`certs/t3-server.crt` and `scripts/publish-android-apk.sh`. Not built, not tested. + +### Deferred to follow-up PRs + +1. `t3code/migration-seam` — the durable `ensureForkSchema` + a one-time guarded + ledger repair. Must account for `t3-claude-import.timer` running a second + migrating process against the same database. +2. Culling `useComposerProjectSkills`, now redundant with upstream's server-side + `ClaudeSkills.ts` (`6b9a5987f`); giving `ProjectionThread.requestingRestart` a + decoding default; switching test-deploy's `--seed copy` from `cpSync` on a hot + WAL database to a real `backup()` that also runs `neutralizeWorkspacePaths`. +3. `scripts/test-deploy-lib.ts` pins `PRUNE_SCHEMA_VERSION = 32` while prod has + been at 33 since 2026-07-09, so the **default curated seed has been silently + falling back for 18 days**. Bump it and republish the template. + ## 2026-07-23 — True merge of upstream through `6f34ad3e8` (2026-07-21) **Strategy change:** this sync is a real `git merge 6f34ad3e8` (~130 commits), not a diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index b59f8572739..67819def623 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -94,6 +94,7 @@ describe("ElectronWindow", () => { webPreferences: { preload: "/tmp/preload.js", partition: "persist:t3code-preview-test", + backgroundThrottling: null, sandbox: true, contextIsolation: true, nodeIntegration: false, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index dacb2eebb47..4671328587a 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -22,6 +22,7 @@ const ElectronWindowCreateOptions = Schema.Struct({ webPreferences: Schema.Struct({ preload: Schema.NullOr(Schema.String), partition: Schema.NullOr(Schema.String), + backgroundThrottling: Schema.NullOr(Schema.Boolean), sandbox: Schema.NullOr(Schema.Boolean), contextIsolation: Schema.NullOr(Schema.Boolean), nodeIntegration: Schema.NullOr(Schema.Boolean), @@ -179,6 +180,7 @@ export const make = Effect.gen(function* () { webPreferences: { preload: webPreferences?.preload ?? null, partition: webPreferences?.partition ?? null, + backgroundThrottling: webPreferences?.backgroundThrottling ?? null, sandbox: webPreferences?.sandbox ?? null, contextIsolation: webPreferences?.contextIsolation ?? null, nodeIntegration: webPreferences?.nodeIntegration ?? null, diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0d5c79cf55a..5988b1e42f9 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -50,6 +50,7 @@ export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; +export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; @@ -60,6 +61,9 @@ export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick- export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; +export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open"; +export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close"; +export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame"; export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 2abf53ac284..4d50ad8d665 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -13,6 +13,7 @@ import { DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, PreviewAnnotationPayloadSchema, @@ -138,6 +139,15 @@ export const hardReload = tabMethod( "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); +export const setColorScheme = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, + payload: DesktopPreviewSetColorSchemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ tabId, colorScheme }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setColorScheme(tabId, colorScheme); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -158,6 +168,16 @@ export const stopRecording = tabMethod( "desktop.ipc.preview.stopRecording", (manager, tabId) => manager.stopRecording(tabId), ); +export const openPictureInPicture = tabMethod( + IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, + "desktop.ipc.preview.openPictureInPicture", + (manager, tabId) => manager.openPictureInPicture(tabId), +); +export const closePictureInPicture = tabMethod( + IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, + "desktop.ipc.preview.closePictureInPicture", + (manager, tabId) => manager.closePictureInPicture(tabId), +); export const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, @@ -346,6 +366,7 @@ export const methods = [ zoomOut, resetZoom, hardReload, + setColorScheme, openDevTools, clearCookies, clearCache, @@ -356,6 +377,8 @@ export const methods = [ captureScreenshot, revealArtifact, copyArtifactToClipboard, + openPictureInPicture, + closePictureInPicture, automationStatus, automationSnapshot, automationClick, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7a51700e0fd..9795f04e8ae 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,9 @@ +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EPIPE") throw err; + }); +} + import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f92cfcf2fa..9f01baeed90 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -160,6 +160,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), + setColorScheme: (tabId, colorScheme) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), @@ -177,6 +179,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), copyArtifactToClipboard: (path) => ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), + pictureInPicture: { + open: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, { tabId }), + close: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }), + }, recording: { startScreencast: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview-pip-preload.ts b/apps/desktop/src/preview-pip-preload.ts new file mode 100644 index 00000000000..384c4129774 --- /dev/null +++ b/apps/desktop/src/preview-pip-preload.ts @@ -0,0 +1,17 @@ +// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. +import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; +import { contextBridge, ipcRenderer } from "electron"; + +import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "./ipc/channels.ts"; + +contextBridge.exposeInMainWorld("previewPictureInPicture", { + onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { + if (typeof frame !== "object" || frame === null) return; + listener(frame as DesktopPreviewRecordingFrame); + }; + ipcRenderer.on(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener); + }, +}); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6e2df118c34..cae6c8940d1 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,6 +1,8 @@ import { it as effectIt } from "@effect/vitest"; +import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -19,7 +21,15 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; +describe("fitPictureInPictureContentSize", () => { + it("fits landscape and portrait viewports without letterboxing the window", () => { + expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([480, 270]); + expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([240, 427]); + }); +}); + const { + browserWindowConstructor, createFromPath, fromId, getFocusedWebContents, @@ -29,8 +39,9 @@ const { writeFile, writeImage, } = vi.hoisted(() => ({ + browserWindowConstructor: vi.fn(), createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), - fromId: vi.fn(() => null), + fromId: vi.fn((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -40,6 +51,7 @@ const { })); vi.mock("electron", () => ({ + BrowserWindow: browserWindowConstructor, clipboard: { writeImage, }, @@ -73,6 +85,10 @@ const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", + dirname: "/tmp/t3/desktop", + path: { + join: (...parts: ReadonlyArray) => parts.join("/"), + }, } as DesktopEnvironment.DesktopEnvironment["Service"]), ); @@ -92,7 +108,7 @@ const layer = PreviewManager.layer.pipe( Layer.provideMerge(environmentLayer), Layer.provideMerge(fileSystemLayer), Layer.provideMerge(Path.layer), - Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")), ); const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); @@ -106,8 +122,73 @@ const withManager = ( return yield* use(manager); }).pipe(Effect.provide(layer), Effect.scoped); +interface TestCapturedPreviewImage { + readonly toJPEG: () => Buffer; + readonly getSize: () => { readonly width: number; readonly height: number }; +} + +const makeTestPreviewWebContents = ( + capturePage: () => Promise, + id = 42, +) => + ({ + id, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + }) as never; + +const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { + const listeners = new Map void>(); + const send = vi.fn(); + let destroyed = false; + const pictureInPictureWindow = { + isDestroyed: vi.fn(() => destroyed), + once: vi.fn((event: string, listener: () => void) => { + listeners.set(event, listener); + }), + setAlwaysOnTop: vi.fn(), + setVisibleOnAllWorkspaces: vi.fn(), + setAspectRatio: vi.fn(), + getContentSize: vi.fn(() => [480, 320]), + setContentSize: vi.fn(), + loadURL: vi.fn(loadURL), + showInactive: vi.fn(() => { + if (destroyed) throw new Error("Picture-in-picture window is closed."); + }), + close: vi.fn(() => { + if (destroyed) return; + destroyed = true; + listeners.get("closed")?.(); + }), + webContents: { + send, + }, + }; + return { pictureInPictureWindow, send }; +}; + describe("PreviewManager", () => { beforeEach(() => { + browserWindowConstructor.mockReset(); fromId.mockClear(); getFocusedWebContents.mockReset(); getFocusedWebContents.mockReturnValue(null); @@ -365,6 +446,153 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("emulates prefers-color-scheme and re-applies it across webview swaps", () => + withManager((manager) => + Effect.gen(function* () { + const makeWebContents = (id: number) => { + const sendCommand = vi.fn(async () => undefined); + return { + sendCommand, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + const first = makeWebContents(42); + fromId.mockReturnValue(first.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_scheme"); + yield* manager.registerWebview("tab_scheme", 42); + yield* Effect.yieldNow; + + yield* manager.setColorScheme("tab_scheme", "dark"); + + expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + const replacement = makeWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_scheme", 43); + yield* Effect.yieldNow; + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + yield* manager.setColorScheme("tab_scheme", "system"); + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "" }], + }); + expect(states.at(-1)?.colorScheme).toBe("system"); + }), + ), + ); + + effectIt.effect("blocks late webview and capture starts during tab close", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("close-race-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const firstWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); + const replacementListenerSpies = replacementWebContents as unknown as { + readonly on: ReturnType; + readonly off: ReturnType; + readonly ipc: { readonly off: ReturnType }; + }; + fromId.mockImplementation((id) => { + if (id === 42) return firstWebContents; + if (id === 43) return replacementWebContents; + return null; + }); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_close_register_race"); + yield* manager.registerWebview("tab_close_register_race", 42); + yield* manager.openPictureInPicture("tab_close_register_race"); + + const closeCleanupPaused = yield* Deferred.make(); + const continueCloseCleanup = yield* Deferred.make(); + yield* manager.subscribeStateChanges((_tabId, state) => + !state.pictureInPicture && state.webContentsId === 42 + ? Deferred.succeed(closeCleanupPaused, undefined).pipe( + Effect.andThen(Deferred.await(continueCloseCleanup)), + ) + : Effect.void, + ); + + const closeFiber = yield* manager + .closeTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(closeCleanupPaused); + const recreateFiber = yield* manager + .createTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + const registrationFiber = yield* manager + .registerWebview("tab_close_register_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + yield* manager.closeTab("tab_close_register_race"); + const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); + yield* Deferred.succeed(continueCloseCleanup, undefined); + yield* Fiber.join(closeFiber); + const recreated = yield* Fiber.join(recreateFiber); + const registrationExit = yield* Fiber.await(registrationFiber); + + for (const exit of [registrationExit, recordingExit]) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) continue; + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewTabNotFoundError", + tabId: "tab_close_register_race", + }); + } + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + expect(replacementListenerSpies.off).not.toHaveBeenCalled(); + expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); + expect(capturePage).toHaveBeenCalledOnce(); + expect(recreated.webContentsId).toBeNull(); + }), + ), + ); + effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => withManager((manager) => Effect.gen(function* () { @@ -530,6 +758,614 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () => + withManager((manager) => + Effect.gen(function* () { + const firstJpeg = Buffer.from("first-recording-frame"); + const secondJpeg = Buffer.from("second-recording-frame"); + const firstCapturePage = vi.fn(async () => ({ + toJPEG: () => firstJpeg, + getSize: () => ({ width: 800, height: 600 }), + })); + const secondCapturePage = vi.fn(async () => ({ + toJPEG: () => secondJpeg, + getSize: () => ({ width: 390, height: 844 }), + })); + const firstSendCommand = vi.fn(async () => undefined); + const secondSendCommand = vi.fn(async () => undefined); + const makeWebContents = ( + id: number, + capturePage: typeof firstCapturePage, + sendCommand: typeof firstSendCommand, + ) => + ({ + id, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => `https://example.com/${id}`, + getTitle: () => `Example ${id}`, + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + }) as never; + const webContentsById = new Map([ + [41, makeWebContents(41, firstCapturePage, firstSendCommand)], + [42, makeWebContents(42, secondCapturePage, secondSendCommand)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), + ); + const frames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + frames.push(frame); + }), + ); + yield* manager.createTab("tab_1"); + yield* manager.createTab("tab_2"); + yield* manager.registerWebview("tab_1", 41); + yield* manager.registerWebview("tab_2", 42); + yield* Effect.all([manager.startRecording("tab_1"), manager.startRecording("tab_2")], { + concurrency: 2, + discard: true, + }); + + expect(firstCapturePage).toHaveBeenCalledOnce(); + expect(secondCapturePage).toHaveBeenCalledOnce(); + expect(frames).toHaveLength(2); + expect(frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tabId: "tab_1", + data: firstJpeg.toString("base64"), + width: 800, + height: 600, + }), + expect.objectContaining({ + tabId: "tab_2", + data: secondJpeg.toString("base64"), + width: 390, + height: 844, + }), + ]), + ); + expect(firstSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + expect(secondSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + + yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], { + concurrency: 2, + discard: true, + }); + }), + ), + ); + + effectIt.effect("drops a captured frame when the tab webview changes during capture", () => + withManager((manager) => + Effect.gen(function* () { + const staleImage: TestCapturedPreviewImage = { + toJPEG: vi.fn(() => Buffer.from("stale-recording-frame")), + getSize: vi.fn(() => ({ width: 1280, height: 720 })), + }; + let markCaptureStarted!: () => void; + const captureStarted = new Promise((resolve) => { + markCaptureStarted = resolve; + }); + let resolveCapture: ((image: TestCapturedPreviewImage) => void) | undefined; + const staleCapturePage = vi.fn(() => { + markCaptureStarted(); + return new Promise((resolve) => { + resolveCapture = resolve; + }); + }); + const replacementCapturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("replacement-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const initialWebContents = makeTestPreviewWebContents(staleCapturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); + fromId.mockImplementation((webContentsId?: number) => { + if (webContentsId === 42) return initialWebContents; + if (webContentsId === 43) return replacementWebContents; + return null; + }); + const frames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + frames.push(frame); + }), + ); + yield* manager.createTab("tab_capture_replaced"); + yield* manager.registerWebview("tab_capture_replaced", 42); + const recordingFiber = yield* manager + .startRecording("tab_capture_replaced") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => captureStarted); + + yield* manager.registerWebview("tab_capture_replaced", 43); + resolveCapture?.(staleImage); + yield* Fiber.join(recordingFiber); + + expect(staleImage.getSize).not.toHaveBeenCalled(); + expect(staleImage.toJPEG).not.toHaveBeenCalled(); + expect(frames).toHaveLength(0); + expect(replacementCapturePage).not.toHaveBeenCalled(); + + yield* manager.stopRecording("tab_capture_replaced"); + }), + ), + ); + + effectIt.effect("keeps an in-flight frame when a capture consumer is added", () => + withManager((manager) => + Effect.gen(function* () { + const image: TestCapturedPreviewImage = { + toJPEG: vi.fn(() => Buffer.from("shared-in-flight-frame")), + getSize: vi.fn(() => ({ width: 1280, height: 720 })), + }; + let markCaptureStarted!: () => void; + const captureStarted = new Promise((resolve) => { + markCaptureStarted = resolve; + }); + let resolveCapture: ((captured: TestCapturedPreviewImage) => void) | undefined; + const capturePage = vi.fn(() => { + markCaptureStarted(); + return new Promise((resolve) => { + resolveCapture = resolve; + }); + }); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const recordingFrames: DesktopPreviewRecordingFrame[] = []; + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + recordingFrames.push(frame); + }), + ); + + yield* manager.createTab("tab_capture_consumer_added"); + yield* manager.registerWebview("tab_capture_consumer_added", 42); + const recordingFiber = yield* manager + .startRecording("tab_capture_consumer_added") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => captureStarted); + + yield* manager.openPictureInPicture("tab_capture_consumer_added"); + resolveCapture?.(image); + yield* Fiber.join(recordingFiber); + + expect(recordingFrames).toHaveLength(1); + expect(send).toHaveBeenCalledWith( + "desktop:preview-pip-frame", + expect.objectContaining({ + tabId: "tab_capture_consumer_added", + data: Buffer.from("shared-in-flight-frame").toString("base64"), + }), + ); + + yield* manager.stopRecording("tab_capture_consumer_added"); + yield* manager.closePictureInPicture("tab_capture_consumer_added"); + }), + ), + ); + + effectIt.effect("shares background frame capture between recording and picture-in-picture", () => + withManager((manager) => + Effect.gen(function* () { + const jpeg = Buffer.from("shared-preview-frame"); + const capturePage = vi.fn(async () => ({ + toJPEG: () => jpeg, + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + } as never); + + const pictureInPictureListeners = new Map void>(); + const pictureInPictureSend = vi.fn(); + const pictureInPictureWindow = { + isDestroyed: vi.fn(() => false), + once: vi.fn((event: string, listener: () => void) => { + pictureInPictureListeners.set(event, listener); + }), + setAlwaysOnTop: vi.fn(), + setVisibleOnAllWorkspaces: vi.fn(), + setAspectRatio: vi.fn(), + getContentSize: vi.fn(() => [480, 320] as [number, number]), + setContentSize: vi.fn(), + loadURL: vi.fn(async () => undefined), + showInactive: vi.fn(), + close: vi.fn(() => { + pictureInPictureListeners.get("closed")?.(); + }), + webContents: { + send: pictureInPictureSend, + }, + }; + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const states: PreviewManager.PreviewTabState[] = []; + const recordingFrames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + recordingFrames.push(frame); + }), + ); + yield* manager.createTab("tab_pip"); + yield* manager.registerWebview("tab_pip", 42); + yield* manager.openPictureInPicture("tab_pip"); + + expect(browserWindowConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + alwaysOnTop: true, + show: false, + skipTaskbar: true, + webPreferences: expect.objectContaining({ + preload: "/tmp/t3/desktop/preview-pip-preload.cjs", + backgroundThrottling: false, + }), + }), + ); + expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }); + expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); + expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(480, 270, false); + expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan( + pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0, + ); + expect(pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0]).toBeLessThan( + pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[1] ?? 0, + ); + expect(pictureInPictureSend).toHaveBeenCalledWith( + "desktop:preview-pip-frame", + expect.objectContaining({ + tabId: "tab_pip", + data: jpeg.toString("base64"), + width: 1280, + height: 720, + }), + ); + expect(states.at(-1)?.pictureInPicture).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + + yield* manager.startRecording("tab_pip"); + expect(capturePage).toHaveBeenCalledOnce(); + expect(recordingFrames).toHaveLength(0); + + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(recordingFrames).toHaveLength(1); + + yield* manager.stopRecording("tab_pip"); + const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan( + framesBeforePictureInPictureOnlyTick, + ); + expect(recordingFrames).toHaveLength(1); + + yield* manager.closePictureInPicture("tab_pip"); + expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); + expect(states.at(-1)?.pictureInPicture).toBe(false); + const capturesAfterClose = capturePage.mock.calls.length; + yield* TestClock.adjust(200); + expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + }), + ), + ); + + effectIt.effect("retries a cold hidden-tab capture without dropping recording", () => + withManager((manager) => + Effect.gen(function* () { + const jpeg = Buffer.from("recovered-preview-frame"); + const capturePage = vi.fn(async () => ({ + toJPEG: () => jpeg, + getSize: () => ({ width: 1280, height: 720 }), + })); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const frames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + frames.push(frame); + }), + ); + yield* manager.createTab("tab_cold_capture"); + yield* manager.registerWebview("tab_cold_capture", 42); + + yield* manager.startRecording("tab_cold_capture"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(frames).toHaveLength(0); + + yield* TestClock.adjust(100); + + expect(capturePage).toHaveBeenCalledTimes(2); + expect(frames).toEqual([ + expect.objectContaining({ + tabId: "tab_cold_capture", + data: jpeg.toString("base64"), + width: 1280, + height: 720, + }), + ]); + + yield* manager.stopRecording("tab_cold_capture"); + }), + ), + ); + + effectIt.effect("drops empty frames before picture-in-picture delivery", () => + withManager((manager) => + Effect.gen(function* () { + const validImage: TestCapturedPreviewImage = { + toJPEG: () => Buffer.from("valid-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const capturePage = vi.fn(async () => validImage); + capturePage.mockResolvedValueOnce({ + toJPEG: () => Buffer.from("empty-preview-frame"), + getSize: () => ({ width: 0, height: 0 }), + }); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_empty_frame"); + yield* manager.registerWebview("tab_empty_frame", 42); + yield* manager.openPictureInPicture("tab_empty_frame"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + + yield* TestClock.adjust(100); + + expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); + expect(send).toHaveBeenCalledOnce(); + yield* manager.closePictureInPicture("tab_empty_frame"); + }), + ), + ); + + effectIt.effect("does not publish picture-in-picture readiness after window teardown", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("closing-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + pictureInPictureWindow.showInactive.mockImplementationOnce(() => { + pictureInPictureWindow.close(); + }); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.createTab("tab_pip_teardown"); + yield* manager.registerWebview("tab_pip_teardown", 42); + const openExit = yield* Effect.exit(manager.openPictureInPicture("tab_pip_teardown")); + + expect(Exit.hasInterrupts(openExit)).toBe(true); + expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); + expect(states.some((state) => state.pictureInPicture)).toBe(false); + expect(states.at(-1)?.pictureInPicture).toBe(false); + }), + ), + ); + + effectIt.effect("closes an initializing picture-in-picture without blocking later opens", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("serialized-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow( + () => + new Promise(() => { + // Simulate a renderer load that never settles. + }), + ); + const { pictureInPictureWindow: reopenedWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor + .mockImplementationOnce(function () { + return initializingWindow; + }) + .mockImplementationOnce(function () { + return reopenedWindow; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_concurrent_pip"); + yield* manager.registerWebview("tab_concurrent_pip", 42); + + const firstOpen = yield* manager + .openPictureInPicture("tab_concurrent_pip") + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + const secondOpen = yield* manager + .openPictureInPicture("tab_concurrent_pip") + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + const close = yield* manager + .closePictureInPicture("tab_concurrent_pip") + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + + expect(browserWindowConstructor).toHaveBeenCalledOnce(); + expect(initializingWindow.loadURL).toHaveBeenCalledOnce(); + expect(initializingWindow.close).toHaveBeenCalledOnce(); + const [firstOpenExit, secondOpenExit] = yield* Effect.all([ + Fiber.await(firstOpen), + Fiber.await(secondOpen), + ]); + yield* Fiber.join(close); + + expect(Exit.hasInterrupts(firstOpenExit)).toBe(true); + expect(Exit.hasInterrupts(secondOpenExit)).toBe(true); + expect(initializingWindow.showInactive).not.toHaveBeenCalled(); + expect(capturePage).not.toHaveBeenCalled(); + expect(states.at(-1)?.pictureInPicture).toBe(false); + + yield* manager.openPictureInPicture("tab_concurrent_pip"); + + expect(browserWindowConstructor).toHaveBeenCalledTimes(2); + expect(reopenedWindow.showInactive).toHaveBeenCalledOnce(); + expect(capturePage).toHaveBeenCalledOnce(); + expect(states.at(-1)?.pictureInPicture).toBe(true); + + yield* manager.closePictureInPicture("tab_concurrent_pip"); + + expect(browserWindowConstructor).toHaveBeenCalledTimes(2); + expect(reopenedWindow.close).toHaveBeenCalledOnce(); + expect(states.at(-1)?.pictureInPicture).toBe(false); + const capturesAfterClose = capturePage.mock.calls.length; + yield* TestClock.adjust(200); + expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + }), + ), + ); + + effectIt.effect("rejects picture-in-picture when its webview changes during initialization", () => + withManager((manager) => + Effect.gen(function* () { + const initialCapturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("stale-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const replacementCapturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("replacement-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const initialWebContents = makeTestPreviewWebContents(initialCapturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); + fromId.mockImplementation((webContentsId?: number) => { + if (webContentsId === 42) return initialWebContents; + if (webContentsId === 43) return replacementWebContents; + return null; + }); + let resolveLoad: (() => void) | undefined; + const { pictureInPictureWindow } = makeTestPictureInPictureWindow( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_replaced_webview"); + yield* manager.registerWebview("tab_replaced_webview", 42); + const open = yield* manager + .openPictureInPicture("tab_replaced_webview") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(pictureInPictureWindow.loadURL).toHaveBeenCalledOnce(); + expect(resolveLoad).toBeDefined(); + const concurrentOpen = yield* manager + .openPictureInPicture("tab_replaced_webview") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + yield* manager.registerWebview("tab_replaced_webview", 43); + resolveLoad?.(); + + const openExits = yield* Effect.all([Fiber.await(open), Fiber.await(concurrentOpen)]); + for (const openExit of openExits) { + expect(Exit.isFailure(openExit)).toBe(true); + if (Exit.isSuccess(openExit)) continue; + const error = Option.getOrThrow(Cause.findErrorOption(openExit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "pictureInPicture.validateWebContents", + tabId: "tab_replaced_webview", + webContentsId: 42, + }); + } + expect(browserWindowConstructor).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.showInactive).not.toHaveBeenCalled(); + expect(initialCapturePage).not.toHaveBeenCalled(); + expect(replacementCapturePage).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("keeps element picking active during subframe navigation", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2d0360ef72c..d8fa674916c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -7,6 +7,7 @@ */ import type { DesktopPreviewAnnotationTheme, + DesktopPreviewColorScheme, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -27,21 +28,16 @@ import type { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { - type BrowserWindow, - type Session, - clipboard, - nativeImage, - shell, - webContents, -} from "electron"; +import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -52,6 +48,7 @@ import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -84,6 +81,8 @@ export interface PreviewTabState { canGoBack: boolean; canGoForward: boolean; zoomFactor: number; + pictureInPicture: boolean; + colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; updatedAt: string; } @@ -99,6 +98,12 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; +const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); +const RECORDING_JPEG_QUALITY = 80; +const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; +const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; +const PICTURE_IN_PICTURE_MIN_WIDTH = 240; +const PICTURE_IN_PICTURE_MIN_HEIGHT = 160; const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; @@ -124,6 +129,54 @@ const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { fontMono: "ui-monospace, monospace", }; +export const buildPreviewPictureInPictureDataUrl = (): string => { + const html = ` + + + + + + + + + Live browser preview + + +`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +}; + +export const fitPictureInPictureContentSize = ( + current: ReadonlyArray, + aspectRatio: number, +): readonly [width: number, height: number] => { + const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH); + const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT); + let width = + currentWidth / currentHeight > aspectRatio ? currentHeight * aspectRatio : currentWidth; + let height = width / aspectRatio; + const minimumScale = Math.max( + 1, + PICTURE_IN_PICTURE_MIN_WIDTH / width, + PICTURE_IN_PICTURE_MIN_HEIGHT / height, + ); + width *= minimumScale; + height *= minimumScale; + return [Math.round(width), Math.round(height)]; +}; + const artifactSiteSlug = (rawUrl: string): string => { try { const url = new URL(rawUrl); @@ -294,6 +347,20 @@ interface ManagedListeners { readonly scope: Scope.Closeable; } +type FrameCaptureConsumer = "picture-in-picture" | "recording"; + +interface FrameCaptureSession { + readonly scope: Scope.Closeable; + readonly consumers: ReadonlySet; +} + +interface PictureInPictureSession { + readonly window: BrowserWindow; + readonly webContentsId: number; + readonly ready: Deferred.Deferred; + readonly initializationScope: Scope.Closeable; +} + interface PickSession { readonly cancel: Effect.Effect; } @@ -378,6 +445,7 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( artifactDirectory: string, + pictureInPicturePreloadPath: string, ) { const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; @@ -413,7 +481,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function >(new Map()); const actionSequenceRef = yield* Ref.make(0); const pointerSequenceRef = yield* Ref.make(0); - const recordingTabIdRef = yield* Ref.make>(Option.none()); + const frameCaptureSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const pictureInPictureSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); + const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); + const closingTabIdsRef = yield* Ref.make>(new Set()); + const tabLifecycleLocks = new Map< + string, + { readonly semaphore: Semaphore.Semaphore; users: number } + >(); + const tabLifecycleGenerations = new Map(); const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => Effect.try({ @@ -444,6 +525,58 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function update(copy); return copy; }; + const withTabLifecycleLock = ( + tabId: string, + effect: Effect.Effect, + ): Effect.Effect => + Effect.suspend(() => { + const lifecycle = tabLifecycleLocks.get(tabId) ?? { + semaphore: Semaphore.makeUnsafe(1), + users: 0, + }; + lifecycle.users += 1; + tabLifecycleLocks.set(tabId, lifecycle); + return lifecycle.semaphore.withPermit(effect).pipe( + Effect.ensuring( + Effect.sync(() => { + lifecycle.users -= 1; + if (lifecycle.users === 0 && tabLifecycleLocks.get(tabId) === lifecycle) { + tabLifecycleLocks.delete(tabId); + } + }), + ), + ); + }); + const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( + tabId: string, + consumer: FrameCaptureConsumer, + ) { + const captureScope = yield* SynchronizedRef.modify(frameCaptureSessionsRef, (sessions) => { + const current = sessions.get(tabId); + if (!current || !current.consumers.has(consumer)) { + return [undefined, sessions] as const; + } + const consumers = new Set(current.consumers); + consumers.delete(consumer); + if (consumers.size > 0) { + return [ + undefined, + replaceMap(sessions, (copy) => { + copy.set(tabId, { ...current, consumers }); + }), + ] as const; + } + return [ + current.scope, + replaceMap(sessions, (copy) => { + copy.delete(tabId); + }), + ] as const; + }); + if (captureScope) { + yield* Scope.close(captureScope, Exit.void).pipe(Effect.ignore); + } + }); const deliverEvent = ( eventKind: "state-change" | "recording-frame" | "pointer-event", @@ -537,15 +670,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); - const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* ( - webContentsId: number, - ) { - const tabs = yield* SynchronizedRef.get(tabsRef); - return ( - Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null - ); - }); - const pushBounded = (buffer: ReadonlyArray, entry: A): ReadonlyArray => [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); @@ -729,42 +853,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( function* (method: string, params: Record) { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise( - { - operation: "ackScreencastFrame", - webContentsId: wc.id, - }, - () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => - deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ); - } - } yield* captureDiagnosticMessage(wc.id, method, params); }, ); @@ -1274,69 +1362,132 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function window: BrowserWindow, ) { yield* Ref.set(mainWindowRef, Option.some(window)); + window.once("closed", () => { + runFork(closeAllPictureInPicture()); + }); }); - const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { + const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* ( + tabId: string, + ) { const updatedAt = yield* currentIso; - const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => { - const existing = tabs.get(tabId); - if (existing) return [existing, tabs] as const; - const initial: PreviewTabState = { - tabId, - webContentsId: null, - navStatus: { kind: "Idle" }, - canGoBack: false, - canGoForward: false, - zoomFactor: DEFAULT_ZOOM_FACTOR, - controller: "none", - updatedAt, - }; + const result = yield* SynchronizedRef.modify( + tabsRef, + ( + tabs, + ): readonly [ + { readonly state: PreviewTabState; readonly created: boolean }, + ReadonlyMap, + ] => { + const existing = tabs.get(tabId); + if (existing) return [{ state: existing, created: false }, tabs] as const; + const initial: PreviewTabState = { + tabId, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + pictureInPicture: false, + colorScheme: "system", + controller: "none", + updatedAt, + }; + return [ + { state: initial, created: true }, + replaceMap(tabs, (copy) => { + copy.set(tabId, initial); + }), + ] as const; + }, + ); + if (result.created) { + tabLifecycleGenerations.set(tabId, (tabLifecycleGenerations.get(tabId) ?? 0) + 1); + } + yield* emit(tabId, result.state); + return result.state; + }); + + const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { + return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId)); + }); + + const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { + if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; + yield* Effect.all( + [ + cancelPickElement(tabId), + closePictureInPicture(tabId), + stopFrameCapture(tabId, "recording"), + ], + { + concurrency: 3, + discard: true, + }, + ); + const tab = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) return [Option.none(), tabs] as const; return [ - initial, + Option.some(current), replaceMap(tabs, (copy) => { - copy.set(tabId, initial); + copy.delete(tabId); }), ] as const; }); - yield* emit(tabId, state); - return state; - }); - - const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { - const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (!tab) return; - yield* cancelPickElement(tabId); - if (tab.webContentsId != null) { + if (Option.isNone(tab)) return; + const closedTab = tab.value; + if (closedTab.webContentsId != null) { yield* Effect.all( - [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], + [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)], { concurrency: 2, discard: true }, ); } const updatedAt = yield* currentIso; const closed: PreviewTabState = { - ...tab, + ...closedTab, webContentsId: null, navStatus: { kind: "Idle" }, canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + pictureInPicture: false, + colorScheme: "system", controller: "none", updatedAt, }; - yield* SynchronizedRef.update(tabsRef, (tabs) => - replaceMap(tabs, (copy) => { - copy.delete(tabId); - }), - ); yield* emit(tabId, closed); }); - const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { + const claimed = yield* Ref.modify(closingTabIdsRef, (closingTabIds) => { + if (closingTabIds.has(tabId)) return [false, closingTabIds] as const; + return [true, new Set([...closingTabIds, tabId])] as const; + }); + if (!claimed) return; + return yield* withTabLifecycleLock(tabId, closeTabUnlocked(tabId)).pipe( + Effect.ensuring( + Ref.update(closingTabIdsRef, (closingTabIds) => { + if (!closingTabIds.has(tabId)) return closingTabIds; + const next = new Set(closingTabIds); + next.delete(tabId); + return next; + }), + ), + ); + }); + + const registerWebviewUnlocked = Effect.fn("PreviewManager.registerWebviewUnlocked")(function* ( tabId: string, webContentsId: number, + expectedGeneration: number | undefined, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (!tab) { + if ( + !tab || + tabLifecycleGenerations.get(tabId) !== expectedGeneration || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { return yield* new PreviewTabNotFoundError({ tabId }); } const wc = webContents.fromId(webContentsId); @@ -1373,53 +1524,71 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { concurrency: 3, discard: true }, ); } + const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if ( + !currentTab || + tabLifecycleGenerations.get(tabId) !== expectedGeneration || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { + return yield* new PreviewTabNotFoundError({ tabId }); + } const zoomFactor = replacedWebContentsId !== null ? yield* attempt( { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => { - wc.setZoomFactor(tab.zoomFactor); - return tab.zoomFactor; + wc.setZoomFactor(currentTab.zoomFactor); + return currentTab.zoomFactor; }, ) : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => wc.getZoomFactor(), ); yield* attachListeners(tabId, wc); - runFork(ensureControlSession(wc).pipe(Effect.ignore)); const registeredAt = yield* currentIso; - const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { - const current = tabs.get(tabId); - if (!current) { + const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => + Effect.gen(function* () { + const current = tabs.get(tabId); + if ( + !current || + tabLifecycleGenerations.get(tabId) !== expectedGeneration || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { + return [ + Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), + tabs, + ] as const; + } + const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const next: PreviewTabState = { + ...current, + webContentsId, + navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + zoomFactor, + updatedAt: registeredAt, + }; return [ - Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), - tabs, + Option.some({ + state: next, + pendingUrl, + }), + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), ] as const; - } - const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; - const next: PreviewTabState = { - ...current, - webContentsId, - navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor, - updatedAt: registeredAt, - }; - return [ - Option.some({ - state: next, - pendingUrl, - }), - replaceMap(tabs, (copy) => { - copy.set(tabId, next); - }), - ] as const; - }); + }), + ); if (Option.isNone(registration)) { + yield* Effect.all([detachControlSession(webContentsId), detachListeners(webContentsId)], { + concurrency: 2, + discard: true, + }); return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; + runFork(restoreControlSession(tabId, wc)); yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), @@ -1439,6 +1608,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); + const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + tabId: string, + webContentsId: number, + ) { + const expectedGeneration = tabLifecycleGenerations.get(tabId); + return yield* withTabLifecycleLock( + tabId, + registerWebviewUnlocked(tabId, webContentsId, expectedGeneration), + ); + }); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -1457,6 +1637,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: current?.canGoBack ?? false, canGoForward: current?.canGoForward ?? false, zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, + pictureInPicture: current?.pictureInPicture ?? false, + colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", updatedAt, }; @@ -1525,7 +1707,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* detachControlSession(wc.id); yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { wc.once("devtools-closed", () => { - if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); + if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc)); }); wc.openDevTools({ mode: "detach" }); }); @@ -1684,6 +1866,79 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); + // Emulated media lives on the CDP debugger session, not the WebContents, so + // it is lost whenever the session detaches (webview swap, DevTools + // open/close) and must be re-applied after every (re)attach. + const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( + tabId: string, + wc: Electron.WebContents, + colorScheme: DesktopPreviewColorScheme, + ) { + yield* ensureControlSession(wc); + yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => + wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [ + { + name: "prefers-color-scheme", + // An empty value clears the override so the page follows the OS. + value: colorScheme === "system" ? "" : colorScheme, + }, + ], + }), + ); + }); + + // Re-establish the control session after a detach, restoring any + // color-scheme override the tab carries. The scheme is read after the + // session attaches so a concurrent setColorScheme is not overwritten with + // a stale snapshot. + const restoreControlSession = (tabId: string, wc: Electron.WebContents) => + Effect.gen(function* () { + const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (beforeAttach?.webContentsId !== wc.id) return; + yield* ensureControlSession(wc); + const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (afterAttach?.webContentsId !== wc.id) { + yield* detachControlSession(wc.id); + return; + } + if (afterAttach.colorScheme !== "system") { + yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => + wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [ + { + name: "prefers-color-scheme", + value: afterAttach.colorScheme, + }, + ], + }), + ); + } + }).pipe(Effect.ignore); + + const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + if (tab.colorScheme !== colorScheme) { + // Record the choice even when the CDP call below can't run yet (no + // webview, DevTools holding the debugger) — it is re-applied on the + // next control-session (re)attach. + yield* update(tabId, { colorScheme }); + } + // Re-read after the update: registerWebview may have swapped the guest + // in the meantime and the override must land on the current one. + const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; + if (webContentsId == null) return; + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* applyColorScheme(tabId, wc, colorScheme); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -1737,40 +1992,485 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); - const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* ( - send: SendCommand, + const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( + tabId: string, + ) { + const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (!captureSession) return; + const wc = yield* requireWebContents(tabId); + const image = yield* attemptPromise( + { + operation: "frameCapture.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ); + const currentCaptureSession = yield* Effect.all( + [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)], + { concurrency: 2 }, + ).pipe( + Effect.map(([captureSessions, tabs]) => { + const current = captureSessions.get(tabId); + return current?.scope === captureSession.scope && + tabs.get(tabId)?.webContentsId === wc.id && + !wc.isDestroyed() + ? current + : undefined; + }), + ); + if (!currentCaptureSession) return; + const size = yield* attempt( + { + operation: "frameCapture.measureFrame", + tabId, + webContentsId: wc.id, + }, + () => image.getSize(), + ); + if ( + !Number.isFinite(size.width) || + !Number.isFinite(size.height) || + size.width <= 0 || + size.height <= 0 + ) { + return; + } + const encoded = yield* attempt( + { + operation: "frameCapture.encodeFrame", + tabId, + webContentsId: wc.id, + }, + () => image.toJPEG(RECORDING_JPEG_QUALITY).toString("base64"), + ); + const receivedAt = yield* currentIso; + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: encoded, + width: size.width, + height: size.height, + receivedAt, + }; + const deliveries: Array> = []; + if (currentCaptureSession.consumers.has("recording")) { + const listeners = yield* Ref.get(recordingFrameListenersRef); + deliveries.push( + Effect.forEach( + listeners, + (listener) => deliverEvent("recording-frame", frame.tabId, () => listener(frame)), + { discard: true }, + ), + ); + } + if (currentCaptureSession.consumers.has("picture-in-picture")) { + const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + )?.window; + if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { + deliveries.push( + Effect.gen(function* () { + const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( + tabId, + ); + const aspectRatio = frame.width / frame.height; + if (previousAspectRatio !== aspectRatio) { + yield* attempt( + { + operation: "pictureInPicture.setAspectRatio", + tabId, + webContentsId: wc.id, + }, + () => { + const contentSize = fitPictureInPictureContentSize( + pictureInPictureWindow.getContentSize(), + aspectRatio, + ); + pictureInPictureWindow.setAspectRatio(0); + pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); + pictureInPictureWindow.setAspectRatio(aspectRatio); + }, + ); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.set(tabId, aspectRatio); + }), + ); + } + yield* attempt( + { + operation: "pictureInPicture.deliverFrame", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.webContents.send( + PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, + frame, + ); + }, + ); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Picture-in-picture frame delivery failed.", { + tabId, + error, + }), + ), + ), + ); + } + } + yield* Effect.all(deliveries, { concurrency: 2, discard: true }); + }); + + const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( + tabId: string, + consumer: FrameCaptureConsumer, ) { - yield* send("Page.enable"); - yield* send("Page.startScreencast", { - format: "jpeg", - quality: 80, - maxWidth: 1600, - maxHeight: 1200, - everyNthFrame: 1, + // Validate the tab synchronously, but treat capturePage failures as + // transient. Chromium can return UnknownVizError while a hidden guest is + // warming its first compositor frame; the scheduled loop should keep the + // consumer alive and recover instead of tearing recording/PiP back down. + yield* requireWebContents(tabId); + const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe( + Effect.andThen(capturePreviewFrame(tabId)), + Effect.catch((error) => + Effect.logWarning("Background preview frame capture failed.", { + tabId, + error, + }), + ), + ); + const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { + return Effect.gen(function* () { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const current = sessions.get(tabId); + if (current) { + if (current.consumers.has(consumer)) { + return [false, sessions] as const; + } + return [ + false, + replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...current, + consumers: new Set([...current.consumers, consumer]), + }); + }), + ] as const; + } + const scope = yield* Scope.fork(parentScope, "sequential"); + yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); + return [ + true, + replaceMap(sessions, (copy) => { + copy.set(tabId, { + scope, + consumers: new Set([consumer]), + }); + }), + ] as const; + }); }); + if (!created) return; + yield* capturePreviewFrame(tabId).pipe( + Effect.catch((error) => + Effect.logWarning("Initial background preview frame was not ready; capture will retry.", { + tabId, + consumer, + error, + }), + ), + ); }); - const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { - const recordingTabId = yield* Ref.get(recordingTabIdRef); - if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { - return yield* new PreviewRecordingAlreadyActiveError({ - requestedTabId: tabId, - activeTabId: recordingTabId.value, + const releasePictureInPicture = Effect.fn("PreviewManager.releasePictureInPicture")(function* ( + tabId: string, + expectedSession: PictureInPictureSession, + closeWindow: boolean, + ) { + const removed = yield* SynchronizedRef.modify(pictureInPictureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== expectedSession) { + return [false, sessions] as const; + } + return [ + true, + replaceMap(sessions, (copy) => { + copy.delete(tabId); + }), + ] as const; + }); + if (!removed) return; + yield* Deferred.interrupt(expectedSession.ready); + yield* Scope.close(expectedSession.initializationScope, Exit.void).pipe(Effect.ignore); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.delete(tabId); + }), + ); + yield* stopFrameCapture(tabId, "picture-in-picture"); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.has(tabId)) { + yield* update(tabId, { pictureInPicture: false }); + } + if (closeWindow && !expectedSession.window.isDestroyed()) { + yield* attempt({ operation: "pictureInPicture.close", tabId }, () => + expectedSession.window.close(), + ).pipe(Effect.ignore); + } + }); + + const closePictureInPictureUnlocked = Effect.fn("PreviewManager.closePictureInPictureUnlocked")( + function* (tabId: string) { + const pictureInPictureSession = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + ); + if (!pictureInPictureSession) { + yield* stopFrameCapture(tabId, "picture-in-picture"); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.has(tabId)) { + yield* update(tabId, { pictureInPicture: false }); + } + return; + } + yield* releasePictureInPicture(tabId, pictureInPictureSession, true); + }, + ); + + const closePictureInPicture = Effect.fn("PreviewManager.closePictureInPicture")(function* ( + tabId: string, + ) { + yield* pictureInPictureMutationSemaphore.withPermit(closePictureInPictureUnlocked(tabId)); + }); + + const closeAllPictureInPicture = Effect.fn("PreviewManager.closeAllPictureInPicture")( + function* () { + const sessions = yield* SynchronizedRef.get(pictureInPictureSessionsRef); + yield* Effect.forEach(sessions.keys(), closePictureInPicture, { + concurrency: "unbounded", + discard: true, }); + }, + ); + + const openPictureInPicture = Effect.fn("PreviewManager.openPictureInPicture")(function* ( + tabId: string, + ) { + const claim = yield* pictureInPictureMutationSemaphore.withPermit( + Effect.gen(function* () { + const existing = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (existing && !existing.window.isDestroyed()) { + return { kind: "existing" as const, session: existing }; + } + if (existing) { + yield* releasePictureInPicture(tabId, existing, false); + } + const wc = yield* requireWebContents(tabId); + const title = yield* attempt( + { + operation: "pictureInPicture.readTitle", + tabId, + webContentsId: wc.id, + }, + () => wc.getTitle().trim(), + ); + const pictureInPictureWindow = yield* attempt( + { + operation: "pictureInPicture.create", + tabId, + webContentsId: wc.id, + }, + () => + new BrowserWindow({ + width: PICTURE_IN_PICTURE_INITIAL_WIDTH, + height: PICTURE_IN_PICTURE_INITIAL_HEIGHT, + minWidth: PICTURE_IN_PICTURE_MIN_WIDTH, + minHeight: PICTURE_IN_PICTURE_MIN_HEIGHT, + title: title.length > 0 ? `Preview · ${title}` : "Browser preview", + show: false, + alwaysOnTop: true, + autoHideMenuBar: true, + fullscreenable: false, + maximizable: false, + minimizable: false, + resizable: true, + skipTaskbar: true, + backgroundColor: "#111111", + ...(hostPlatform === "darwin" ? { type: "panel" as const } : {}), + webPreferences: { + preload: pictureInPicturePreloadPath, + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }), + ); + const initializationScope = yield* Scope.fork(parentScope, "sequential"); + const ready = yield* Deferred.make(); + const session: PictureInPictureSession = { + window: pictureInPictureWindow, + webContentsId: wc.id, + ready, + initializationScope, + }; + const onClosed = () => { + runFork( + pictureInPictureMutationSemaphore.withPermit( + releasePictureInPicture(tabId, session, false), + ), + ); + }; + yield* attempt( + { + operation: "pictureInPicture.configure", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.once("closed", onClosed); + pictureInPictureWindow.setAlwaysOnTop( + true, + hostPlatform === "darwin" ? "floating" : "normal", + ); + if (hostPlatform === "darwin") { + pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + // Electron otherwise temporarily transforms the entire app into + // a UIElement process, which removes the owning app from the Dock. + skipTransformProcessType: true, + }); + } + }, + ).pipe( + Effect.onError(() => + Effect.all( + [ + Scope.close(initializationScope, Exit.void).pipe(Effect.ignore), + attempt({ operation: "pictureInPicture.close", tabId }, () => + pictureInPictureWindow.close(), + ).pipe(Effect.ignore), + ], + { discard: true }, + ), + ), + ); + yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.set(tabId, session); + }), + ); + return { kind: "created" as const, session }; + }), + ); + const pictureInPictureSession = claim.session; + if (claim.kind === "existing") { + yield* Deferred.await(pictureInPictureSession.ready); + return yield* pictureInPictureMutationSemaphore.withPermit( + Effect.gen(function* () { + const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { + return yield* new PreviewOperationError({ + operation: "pictureInPicture.showExisting", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + cause: new Error("Picture-in-picture session closed before it became visible."), + }); + } + yield* attempt( + { + operation: "pictureInPicture.showExisting", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + }, + () => pictureInPictureSession.window.showInactive(), + ); + }), + ); } - const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "recording.start", startScreencast); - yield* Ref.set(recordingTabIdRef, Option.some(tabId)); + + const initialize = Effect.gen(function* () { + yield* attemptPromise( + { + operation: "pictureInPicture.load", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + }, + () => pictureInPictureSession.window.loadURL(buildPreviewPictureInPictureDataUrl()), + ); + const currentWebContents = yield* requireWebContents(tabId); + if ( + currentWebContents.id !== pictureInPictureSession.webContentsId || + currentWebContents.isDestroyed() + ) { + return yield* new PreviewOperationError({ + operation: "pictureInPicture.validateWebContents", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + cause: new Error("Preview webview changed while picture-in-picture was opening."), + }); + } + yield* startFrameCapture(tabId, "picture-in-picture"); + yield* attempt( + { + operation: "pictureInPicture.show", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + }, + () => pictureInPictureSession.window.showInactive(), + ); + }); + const initializationExit = yield* Effect.gen(function* () { + const initializationFiber = yield* Effect.forkIn( + initialize, + pictureInPictureSession.initializationScope, + ); + return yield* Fiber.await(initializationFiber); + }).pipe( + Effect.onInterrupt(() => + pictureInPictureMutationSemaphore.withPermit( + releasePictureInPicture(tabId, pictureInPictureSession, true), + ), + ), + ); + if (Exit.isSuccess(initializationExit)) { + const published = yield* pictureInPictureMutationSemaphore.withPermit( + Effect.gen(function* () { + const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { + if (current === pictureInPictureSession) { + yield* releasePictureInPicture(tabId, pictureInPictureSession, false); + } + return false; + } + yield* update(tabId, { pictureInPicture: true }); + yield* Deferred.done(pictureInPictureSession.ready, initializationExit); + return true; + }), + ); + if (published) return; + return yield* Deferred.await(pictureInPictureSession.ready); + } + yield* Deferred.done(pictureInPictureSession.ready, initializationExit); + const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (current === pictureInPictureSession) { + yield* pictureInPictureMutationSemaphore.withPermit( + releasePictureInPicture(tabId, pictureInPictureSession, true), + ); + } + return yield* Effect.failCause(initializationExit.cause); + }); + + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + yield* startFrameCapture(tabId, "recording"); }); const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { - const recordingTabId = yield* Ref.get(recordingTabIdRef); - if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return; - const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "recording.stop", (send) => - send("Page.stopScreencast").pipe(Effect.asVoid), - ); - yield* Ref.set(recordingTabIdRef, Option.none()); + yield* stopFrameCapture(tabId, "recording"); }); const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( @@ -2518,6 +3218,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function goForward, hardReload, navigate, + openPictureInPicture, openDevTools, pickElement, refresh, @@ -2526,8 +3227,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function revealArtifact, saveRecording, setAnnotationTheme, + setColorScheme, setMainWindow, startRecording, + closePictureInPicture, stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), @@ -2613,18 +3316,6 @@ export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass()( - "PreviewRecordingAlreadyActiveError", - { - requestedTabId: Schema.String, - activeTabId: Schema.String, - }, -) { - override get message(): string { - return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`; - } -} - export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass()( "PreviewAutomationDevToolsOpenError", { webContentsId: Schema.Number }, @@ -2787,7 +3478,6 @@ export const PreviewManagerError = Schema.Union([ PreviewOperationError, PreviewArtifactPathOutsideDirectoryError, PreviewArtifactImageLoadError, - PreviewRecordingAlreadyActiveError, PreviewAutomationDevToolsOpenError, PreviewAutomationDebuggerAttachedError, PreviewAutomationEvaluationError, @@ -2830,6 +3520,10 @@ export class PreviewManager extends Context.Service< readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; + readonly setColorScheme: ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -2846,6 +3540,8 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly revealArtifact: (path: string) => Effect.Effect; readonly copyArtifactToClipboard: (path: string) => Effect.Effect; + readonly openPictureInPicture: (tabId: string) => Effect.Effect; + readonly closePictureInPicture: (tabId: string) => Effect.Effect; readonly startRecording: (tabId: string) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( @@ -2896,7 +3592,10 @@ export class PreviewManager extends Context.Service< export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; - const operations = yield* makeNativeOperations(environment.browserArtifactsDir); + const operations = yield* makeNativeOperations( + environment.browserArtifactsDir, + environment.path.join(environment.dirname, "preview-pip-preload.cjs"), + ); return PreviewManager.of({ setMainWindow: operations.setMainWindow, @@ -2921,6 +3620,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, hardReload: operations.hardReload, + setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSession @@ -2953,6 +3653,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { captureScreenshot: operations.captureScreenshot, revealArtifact: operations.revealArtifact, copyArtifactToClipboard: operations.copyArtifactToClipboard, + openPictureInPicture: operations.openPictureInPicture, + closePictureInPicture: operations.closePictureInPicture, startRecording: operations.startRecording, stopRecording: operations.stopRecording, saveRecording: operations.saveRecording, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 81d33c5db70..8bfc38d0db1 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,7 +19,9 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], + composerAutoRuntimeModeVisible: false, fileExplorerShowDotfiles: true, + glassOpacity: 80, headerGitActionsVisibility: "auto", headerOpenInEditorVisibility: "auto", headerProjectScriptsVisibility: "auto", @@ -32,7 +34,7 @@ const clientSettings: ClientSettings = { headerUsageStockSymbol: "SPY", headerUsageWeeklyVisible: false, providerModelPreferences: {}, - sidebarChatListView: "grouped", + sidebarAutoSettleAfterDays: 3, sidebarHostStatsVisible: false, sidebarHostStatsStyle: "classic", sidebarProjectGroupingMode: "repository_path", @@ -42,6 +44,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarV2Enabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..3cd06cfcf32 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -315,6 +315,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n webPreferences: { preload: null, partition: null, + backgroundThrottling: null, sandbox: null, contextIsolation: null, nodeIntegration: null, @@ -429,6 +430,7 @@ describe("DesktopWindow", () => { assert.isUndefined(createdWindowOptions[0]?.x); assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); + assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..40788009d3b 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -331,6 +331,7 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, + backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, sandbox: true, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 96e089b9183..9f25204f163 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -73,5 +73,12 @@ export default defineConfig({ alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), }, }, + { + format: "cjs", + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/preview-pip-preload.ts"], + }, ], }); diff --git a/apps/marketing/package.json b/apps/marketing/package.json index d122dac3c56..912faf88164 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -10,6 +10,7 @@ "typecheck": "astro check" }, "dependencies": { + "@t3tools/shared": "workspace:*", "astro": "^7.0.3" }, "devDependencies": { diff --git a/apps/marketing/src/pages/schema/t3.json.ts b/apps/marketing/src/pages/schema/t3.json.ts new file mode 100644 index 00000000000..707523eafc5 --- /dev/null +++ b/apps/marketing/src/pages/schema/t3.json.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from "astro"; + +import { buildT3ProjectFileJsonSchema } from "@t3tools/shared/t3ProjectFile"; + +// Rendered at build time; published at https://t3.codes/schema/t3.json so +// t3.json files can reference it via "$schema" for editor/LSP support. +export const GET: APIRoute = () => + new Response(`${JSON.stringify(buildT3ProjectFileJsonSchema(), null, 2)}\n`, { + headers: { "Content-Type": "application/json" }, + }); diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index c7344e9ff78..d2a3c774b8a 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -1,7 +1,7 @@ import type { VercelConfig } from "@vercel/config/v1"; export const config: VercelConfig = { - installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing'", + installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", }; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 835f54491f6..06bd4bc5773 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -13,7 +13,10 @@ import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; -import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; +import { + AppearancePreferencesProvider, + useAppearancePreferences, +} from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; @@ -26,6 +29,10 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { prepareNativeShowcaseCapture(); } +void SplashScreen.preventAutoHideAsync().catch(() => { + // The native module can be unavailable in non-native test environments. +}); + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], // The Expo dev client launches the app via @@ -40,18 +47,25 @@ const appLinking = { const Navigation = createStaticNavigation(RootStack); +function SplashScreenCoordinator() { + const { isReady } = useAppearancePreferences(); + + useEffect(() => { + if (isReady) void SplashScreen.hide(); + }, [isReady]); + + return null; +} + export default function App() { const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); - useEffect(() => { - SplashScreen.hide(); - }, []); - return ( + diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index cc51b56ee70..b8a13137c88 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -33,7 +33,7 @@ import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; import { connectionStorageLayer } from "./storage"; function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { - if (state.isConnected === false || state.isInternetReachable === false) { + if (state.isConnected === false) { return "offline"; } if (state.isConnected === true) { diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..697d13e7c47 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 5423686e048..5d619c688f1 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -143,6 +143,8 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { return `Relay could not link the environment (${error.reason}).`; case "RelayEnvironmentLinkUnavailableError": return `Relay cannot provision the managed endpoint (${error.reason}).`; + case "RelayEnvironmentLinkLimitExceededError": + return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; case "RelayAgentActivityPublishProofExpiredError": return "Relay rejected an expired agent activity publish proof."; case "RelayAgentActivityPublishProofInvalidError": diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 3a0342a8214..36cead9f8cc 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,9 +1,7 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -14,6 +12,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -21,10 +20,10 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, + type HomeListFilterMenuProject, } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, - PROJECT_GROUPING_OPTIONS, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; @@ -33,16 +32,17 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { @@ -59,11 +59,24 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } +/** Thread List v2 lays the list out in fixed creation order, so the + sort/group filter controls would be silently ignored — hide them and + key the "customized" icon state off the environment filter alone. */ +function useThreadListV2FilterGate() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + return ( + AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true + ); +} + function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const menuActions = useMemo( () => [ { @@ -82,40 +95,57 @@ function AndroidHomeHeader(props: HomeHeaderProps) { })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectGroupingMode === option.value), - })), - }, + ...(props.projects.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(props.selectedProjectKey === null), + }, + ...props.projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(props.selectedProjectKey === project.key), + })), + ], + }, + ] satisfies MenuAction[])), + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectSortOrder === option.value), + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.threadSortOrder === option.value), + })), + }, + ] satisfies MenuAction[])), ], [ props.environments, - props.projectGroupingMode, props.projectSortOrder, + props.projects, props.selectedEnvironmentId, + props.selectedProjectKey, props.threadSortOrder, + threadListV2Enabled, ], ); const handleMenuAction = useCallback( @@ -137,6 +167,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id === "project:all") { + props.onProjectChange(null); + return; + } + + if (id.startsWith("project:")) { + const projectKey = id.slice("project:".length); + if (props.projects.some((project) => project.key === projectKey)) { + props.onProjectChange(projectKey); + } + return; + } + const projectSort = PROJECT_SORT_OPTIONS.find( (option) => id === `project-sort:${option.value}`, ); @@ -150,13 +193,6 @@ function AndroidHomeHeader(props: HomeHeaderProps) { props.onThreadSortOrderChange(threadSort.value); return; } - - const grouping = PROJECT_GROUPING_OPTIONS.find( - (option) => id === `project-grouping:${option.value}`, - ); - if (grouping) { - props.onProjectGroupingModeChange(grouping.value); - } }, [props], ); @@ -255,17 +291,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); + const filterMenu = buildHomeListFilterMenu({ + ...props, + listOrganization: !threadListV2Enabled, + }); return ( <> - - Settings - - Environment + {props.projects.length > 0 ? ( + + Project + props.onProjectChange(null)} + subtitle="Show threads from every project" + > + All projects + + {props.projects.map((project) => ( + props.onProjectChange(project.key)} + > + {project.label} + + ))} + + ) : null} + Sort projects {PROJECT_SORT_OPTIONS.map((option) => ( @@ -391,20 +452,6 @@ function IosHomeHeader(props: HomeHeaderProps) { ))} - - - Group projects - {PROJECT_GROUPING_OPTIONS.map((option) => ( - props.onProjectGroupingModeChange(option.value)} - subtitle={option.subtitle} - > - {option.label} - - ))} - diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 49cf06d85ec..9fa179f4c76 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,7 +1,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -15,6 +15,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; +import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; @@ -28,7 +29,8 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -52,11 +54,31 @@ export function HomeRouteScreen() { const { options: listOptions, setSelectedEnvironmentId, - setProjectGroupingMode, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectFilterOptions = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }).map((scope) => ({ + key: scope.key, + label: scope.title, + })), + [listOptions.projectGroupingMode, projects, selectedEnvironmentId], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. @@ -89,14 +111,15 @@ export function HomeRouteScreen() { navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} @@ -111,15 +134,19 @@ export function HomeRouteScreen() { } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} + onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { + // Settled threads are live shells: opening one is plain + // navigation, and sending a message un-settles server-side. navigation.navigate("Thread", { environmentId: thread.environmentId, threadId: thread.id, @@ -146,6 +173,7 @@ export function HomeRouteScreen() { savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} + selectedProjectKey={selectedProjectKey} threads={threads} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index dcc56fd77c5..54e242c2166 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -14,18 +14,20 @@ import type { } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -33,6 +35,13 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; +import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { + buildThreadListV2Items, + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListV2Item, +} from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -43,7 +52,12 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "./homeListItems"; -import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; +import { + buildHomeProjectScopes, + buildHomeThreadGroups, + sortHomeProjectScopes, + type HomeProjectSortOrder, +} from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; @@ -59,14 +73,15 @@ interface HomeScreenProps { readonly environments: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onAddConnection: () => void; readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; @@ -74,6 +89,9 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + /** Resolves true iff the settle was dispatched and succeeded. */ + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -163,6 +181,9 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -224,12 +245,77 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects: props.projects, + environmentId: props.selectedEnvironmentId, + projectGroupingMode: props.projectGroupingMode, + }), + [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], + ); + const selectedProjectScope = useMemo( + () => + props.selectedProjectKey === null + ? null + : (projectScopes.find( + (scope) => + scope.key === props.selectedProjectKey || + scope.projectRefs.some( + (projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId) === + props.selectedProjectKey, + ), + ) ?? null), + [projectScopes, props.selectedProjectKey], + ); + const selectedProjectRefKeys = useMemo( + () => + selectedProjectScope === null + ? null + : new Set( + selectedProjectScope.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), + ), + [selectedProjectScope], + ); + const scopedProjects = useMemo( + () => + selectedProjectRefKeys === null + ? props.projects + : props.projects.filter((project) => + selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), + ), + [props.projects, selectedProjectRefKeys], + ); + const scopedThreads = useMemo( + () => + selectedProjectRefKeys === null + ? props.threads + : props.threads.filter((thread) => + selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), + ), + [props.threads, selectedProjectRefKeys], + ); + const scopedPendingTasks = useMemo( + () => + selectedProjectRefKeys === null + ? props.pendingTasks + : props.pendingTasks.filter((pendingTask) => + selectedProjectRefKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), + ), + [props.pendingTasks, selectedProjectRefKeys], + ); + const projectGroups = useMemo( () => buildHomeThreadGroups({ - projects: props.projects, - threads: props.threads, - pendingTasks: props.pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: props.projectSortOrder, @@ -237,14 +323,14 @@ export function HomeScreen(props: HomeScreenProps) { projectGroupingMode: props.projectGroupingMode, }), [ - props.pendingTasks, props.projectGroupingMode, - props.projects, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, - props.threads, + scopedPendingTasks, + scopedProjects, + scopedThreads, ], ); @@ -267,6 +353,269 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of props.projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [props.projects]); + + const v2ProjectScopeKey = props.selectedProjectKey; + const v2ScopeProjects = useMemo( + () => + sortHomeProjectScopes({ + scopes: projectScopes, + threads: props.threads, + pendingTasks: props.pendingTasks, + projectSortOrder: props.projectSortOrder, + }), + [ + props.pendingTasks, + props.projects, + props.projectSortOrder, + props.selectedEnvironmentId, + props.threads, + projectScopes, + ], + ); + const v2ScopedProjectGroup = useMemo( + () => + v2ProjectScopeKey === null + ? null + : (v2ScopeProjects.find( + (scope) => + scope.key === v2ProjectScopeKey || + scope.projectRefs.some( + (projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId) === + v2ProjectScopeKey, + ), + ) ?? null), + [v2ProjectScopeKey, v2ScopeProjects], + ); + const v2ProjectTitleByProjectKey = useMemo( + () => + new Map( + v2ScopeProjects.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [ + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.title, + ] as const, + ), + ), + ), + [v2ScopeProjects], + ); + const v2ScopedProjectKeys = useMemo( + () => + v2ScopedProjectGroup === null + ? null + : new Set( + v2ScopedProjectGroup.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), + ), + [v2ScopedProjectGroup], + ); + // Thread List v2 (beta): one flat list in creation order, no grouping. + // Settled threads collapse into a recency tail below the card block. + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells — no snapshot merging or + // optimistic holds. + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition (mirrors web). + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + const handleSettleThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onSettleThread(thread); + }, + [props.onSettleThread], + ); + const handleDeleteThread = props.onDeleteThread; + const handleUnsettleThread = props.onUnsettleThread; + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // now is quantized to the minute and ticks so the inactivity auto-settle + // boundary is actually crossed while the app stays open (mirrors web); + // without a clock dependency the partition memoizes a frozen "now". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + useEffect(() => { + if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; + // Settled threads are live shells; archived threads keep their original + // "hidden from lists" meaning. + return buildThreadListV2Items({ + threads: props.threads.filter((thread) => thread.archivedAt === null), + environmentId: props.selectedEnvironmentId, + projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + snoozeEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), + }); + }, [ + changeRequestStateByKey, + nowMinute, + snoozeWakeTick, + settledVisibleCount, + settlementEnvironmentIds, + snoozeEnvironmentIds, + props.searchQuery, + props.selectedEnvironmentId, + props.threads, + threadListV2Enabled, + v2ScopedProjectGroup, + ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); + const threadListV2Items = threadListV2Layout.items; + + const renderV2Item = useCallback( + ({ item }: { readonly item: ThreadListV2Item }) => ( + + provider.instanceId === + (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), + )?.driver ?? null + } + environmentLabel={ + Object.keys(props.savedConnectionsById).length > 1 + ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) + : null + } + onSelectThread={props.onSelectThread} + onDeleteThread={handleDeleteThread} + onArchiveThread={props.onArchiveThread} + settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} + onSettleThread={handleSettleThread} + onUnsettleThread={handleUnsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={ + projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? + null + } + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + ), + [ + handleChangeRequestState, + handleDeleteThread, + handleSettleThread, + handleSwipeableClose, + handleSwipeableWillOpen, + handleUnsettleThread, + projectByKey, + projectCwdByKey, + props.onArchiveThread, + props.onSelectThread, + props.savedConnectionsById, + serverConfigs, + settlementEnvironmentIds, + v2ProjectTitleByProjectKey, + ], + ); + const v2KeyExtractor = useCallback( + (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, + [], + ); + const extraData = useMemo( () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), [props.savedConnectionsById, projectCwdByKey], @@ -360,6 +709,10 @@ export function HomeScreen(props: HomeScreenProps) { const keyExtractor = useCallback((item: HomeListItem) => item.key, []); /* Empty states */ + // The signal must ignore the search/environment filters: an active query + // that matches nothing needs the in-list "No results" state, not the + // full-page "No threads yet". Settled threads are unarchived live shells, + // so the v1 check already covers v2. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; const hasResults = projectGroups.length > 0; @@ -436,9 +789,50 @@ export function HomeScreen(props: HomeScreenProps) { ); + // v2 renders queued offline tasks above the thread cards — they are not + // thread shells, so the v2 item builder never sees them, but they must + // stay visible and deletable while their environment is offline. They + // respect the same environment scope and search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.message.environmentId === props.selectedEnvironmentId) && + (v2ScopedProjectKeys === null || + v2ScopedProjectKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + )) && + (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + // Project scoping lives in the header filter menu (no inline chip row on + // mobile — the menu is the one filter surface). + const v2ListHeader = ( + <> + {listHeader} + {v2PendingTasks.map((pendingTask, index) => ( + + ))} + + ); + const listEmpty = !hasResults ? ( hasSearchQuery ? ( + ) : selectedProjectScope !== null ? ( + ) : selectedEnvironmentLabel ? ( ) ) : null; + // Self-contained: v1's listEmpty keys off projectGroups, which ignores the + // v2 project scope, so it can be null (results elsewhere) while this list + // is empty. Search outranks the scope — "No results" names the actionable + // fact when a query is active. Snoozed threads outrank the rest: "No + // threads yet" over an inbox that is merely all-snoozed reads as data + // loss. Pending tasks render in the header, so the list showing them + // isn't empty in the user's eyes. + const v2SnoozedCount = threadListV2Layout.snoozedCount; + const v2ListEmpty = + v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. + + ) : ( + + ) + ) : v2SnoozedCount > 0 ? ( + + ) : v2ScopedProjectGroup !== null ? ( + + ) : ( + listEmpty + ); + + if (threadListV2Enabled) { + return ( + + + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingBottom: + Platform.OS === "ios" + ? Math.max(insets.bottom, 24) + 96 + : Math.max(insets.bottom, 16) + 88, + }} + /> + + {connectionStatus} + + ); + } return ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts new file mode 100644 index 00000000000..99e3cb36c07 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { buildHomeListFilterMenu } from "./home-list-filter-menu"; + +describe("buildHomeListFilterMenu", () => { + it("adds a project scope submenu that selects and clears the same scope as the chips", () => { + const onProjectChange = vi.fn(); + const menu = buildHomeListFilterMenu({ + environments: [], + projects: [ + { key: "environment-1:project-1", label: "Codething" }, + { key: "environment-1:project-2", label: "Website" }, + ], + selectedEnvironmentId: null, + selectedProjectKey: "environment-1:project-1", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + onEnvironmentChange: vi.fn(), + onProjectChange, + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + }); + + const projectMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Project", + ); + expect(menu.items.some((item) => item.title === "Settings")).toBe(false); + expect(projectMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "All projects", state: "off" }, + { title: "Codething", state: "on" }, + { title: "Website", state: "off" }, + ], + }); + if (projectMenu?.type !== "submenu") throw new Error("Expected project submenu"); + + projectMenu.items[0]?.onPress(); + projectMenu.items[2]?.onPress(); + expect(onProjectChange).toHaveBeenNthCalledWith(1, null); + expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2"); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index 46ea639677b..edd0176f862 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,21 +1,18 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { HomeProjectSortOrder } from "./homeThreadList"; -import { - PROJECT_GROUPING_OPTIONS, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, -} from "./home-list-options"; +import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; readonly label: string; } +export interface HomeListFilterMenuProject { + readonly key: string; + readonly label: string; +} + type HomeListFilterMenuAction = { readonly type: "action"; readonly title: string; @@ -37,81 +34,91 @@ export interface HomeListFilterMenu { export function buildHomeListFilterMenu(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; - readonly onOpenSettings?: () => void; + /** False hides the sort/group submenus. Thread List v2 uses a fixed + creation-order layout, so offering those controls while it silently + ignores them would be a lie; the environment filter still applies. */ + readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; - if (props.onOpenSettings) { - items.push({ - type: "action", - title: "Settings", - onPress: props.onOpenSettings, - }); - } + items.push({ + type: "submenu", + title: "Environment", + items: [ + { + type: "action", + title: "All environments", + subtitle: "Show threads from every environment", + state: props.selectedEnvironmentId === null ? "on" : "off", + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + type: "action" as const, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : ("off" as const), + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }); - items.push( - { + if (props.projects.length > 0) { + items.push({ type: "submenu", - title: "Environment", + title: "Project", items: [ { type: "action", - title: "All environments", - subtitle: "Show threads from every environment", - state: props.selectedEnvironmentId === null ? "on" : "off", - onPress: () => props.onEnvironmentChange(null), + title: "All projects", + subtitle: "Show threads from every project", + state: props.selectedProjectKey === null ? "on" : "off", + onPress: () => props.onProjectChange(null), }, - ...props.environments.map((environment) => ({ + ...props.projects.map((project) => ({ type: "action" as const, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), + title: project.label, + state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + onPress: () => props.onProjectChange(project.key), })), ], - }, - { - type: "submenu", - title: "Sort projects", - items: PROJECT_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.projectSortOrder === option.value ? "on" : "off", - onPress: () => props.onProjectSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Sort threads", - items: THREAD_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.threadSortOrder === option.value ? "on" : "off", - onPress: () => props.onThreadSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Group projects", - items: PROJECT_GROUPING_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - subtitle: option.subtitle, - state: props.projectGroupingMode === option.value ? "on" : "off", - onPress: () => props.onProjectGroupingModeChange(option.value), - })), - }, - ); + }); + } + + if (props.listOrganization !== false) { + items.push( + { + type: "submenu", + title: "Sort projects", + items: PROJECT_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.projectSortOrder === option.value ? "on" : "off", + onPress: () => props.onProjectSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Sort threads", + items: THREAD_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.threadSortOrder === option.value ? "on" : "off", + onPress: () => props.onThreadSortOrderChange(option.value), + })), + }, + ); + } return { title: "Thread list options", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 788be25906b..ac3893956ca 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -1,5 +1,4 @@ import { - DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, } from "@t3tools/contracts"; @@ -14,7 +13,6 @@ const defaults: HomeListOptions = { ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, }; describe("home list options", () => { @@ -22,10 +20,12 @@ describe("home list options", () => { expect(hasCustomHomeListOptions(defaults)).toBe(false); }); - it("marks environment filters and grouping changes as customized", () => { + it("marks environment filters as customized", () => { expect( hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), ).toBe(true); - expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true); + expect( + hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 64881034306..d70e2537bae 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -4,7 +4,6 @@ import type { SidebarThreadSortOrder, } from "@t3tools/contracts"; import { - DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, } from "@t3tools/contracts"; @@ -26,9 +25,18 @@ export interface HomeListOptions { readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; +} + +export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } +export function resolveProjectGroupingMode( + projectGroupingEnabled: boolean | undefined, +): SidebarProjectGroupingMode { + return projectGroupingEnabled === false ? "separate" : "repository"; +} + export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ readonly value: HomeProjectSortOrder; readonly label: string; @@ -45,28 +53,6 @@ export const THREAD_SORT_OPTIONS: ReadonlyArray<{ { value: "created_at", label: "Created at" }, ]; -export const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ - readonly value: SidebarProjectGroupingMode; - readonly label: string; - readonly subtitle: string; -}> = [ - { - value: "repository", - label: "Group by repository", - subtitle: "Combine matching repositories across environments", - }, - { - value: "repository_path", - label: "Group by repository path", - subtitle: "Combine only matching paths within a repository", - }, - { - value: "separate", - label: "Keep separate", - subtitle: "Show every project path separately", - }, -]; - function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentId: null, @@ -75,34 +61,44 @@ function defaultHomeListOptions(): HomeListOptions { ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, }; } interface HomeListOptionsContextValue { readonly options: HomeListOptions; readonly setOptions: Dispatch>; + readonly projectGroupingMode: SidebarProjectGroupingMode; } const HomeListOptionsContext = createContext(null); /** Keeps list preferences stable while the app moves between compact and split shells. */ -export function HomeListOptionsProvider({ children }: PropsWithChildren) { +export function HomeListOptionsProvider({ + children, + projectGroupingMode, +}: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode }>) { const [options, setOptions] = useState(defaultHomeListOptions); - const value = useMemo(() => ({ options, setOptions }), [options]); + const value = useMemo( + () => ({ options, setOptions, projectGroupingMode }), + [options, projectGroupingMode], + ); return createElement(HomeListOptionsContext, { value }, children); } -export function hasCustomHomeListOptions(options: HomeListOptions): boolean { +export function hasCustomHomeListOptions( + options: HomeListOptions & { + readonly selectedProjectKey?: string | null; + }, +): boolean { const defaultProjectSortOrder = DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; return ( options.selectedEnvironmentId !== null || + (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.projectSortOrder !== defaultProjectSortOrder || - options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || - options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE + options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ); } @@ -116,10 +112,14 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, selectedEnvironmentId: value })); @@ -130,15 +130,10 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, threadSortOrder: value })); }, []); - const setProjectGroupingMode = useCallback((value: SidebarProjectGroupingMode) => { - setOptions((current) => ({ ...current, projectGroupingMode: value })); - }, []); - return { options: resolvedOptions, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder, - setProjectGroupingMode, } as const; } diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 36e98ef129f..c5a9f2c6bbc 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8f13000b9e2..e791cd3b36a 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -5,7 +5,11 @@ import type { import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { buildHomeThreadGroups } from "./homeThreadList"; +import { + buildHomeProjectScopes, + buildHomeThreadGroups, + sortHomeProjectScopes, +} from "./homeThreadList"; function makeProject( input: Partial & Pick, @@ -41,6 +45,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } @@ -65,6 +71,346 @@ function buildGroups( } describe("buildHomeThreadGroups", () => { + it("builds one v2 scope for the same repository across environments", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const projects = [ + makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-local"), + title: "t3code", + repositoryIdentity, + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote"), + title: "t3code", + repositoryIdentity, + }), + ]; + + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }); + + expect(scopes).toHaveLength(1); + expect(scopes[0]?.title).toBe("t3code"); + expect(scopes[0]?.projects).toEqual(projects); + expect(scopes[0]?.projectRefs).toEqual( + projects.map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + ); + }); + + it("routes stale duplicate project refs through the canonical repository group", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const local = makeProject({ + id: ProjectId.make("project-local"), + environmentId: localEnvironmentId, + title: "t3code", + workspaceRoot: "/workspaces/t3code", + repositoryIdentity, + }); + const stale = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-stale"), + title: "t3code", + workspaceRoot: "/remote/t3code", + updatedAt: "2026-06-01T00:00:00.000Z", + }); + const canonicalRemote = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-canonical-remote"), + title: "t3code", + workspaceRoot: "/remote/t3code/", + repositoryIdentity, + updatedAt: "2026-06-02T00:00:00.000Z", + }); + const projects = [local, stale, canonicalRemote]; + const staleThread = makeThread({ + environmentId: remoteEnvironmentId, + id: ThreadId.make("thread-stale-project-ref"), + projectId: stale.id, + title: "Still visible", + updatedAt: "2026-06-03T00:00:00.000Z", + }); + + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }); + const groups = buildGroups(projects, [staleThread]); + + expect(scopes).toHaveLength(1); + expect(scopes[0]?.projects.map((project) => project.id)).toEqual([ + local.id, + canonicalRemote.id, + ]); + expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([ + local.id, + stale.id, + canonicalRemote.id, + ]); + expect(groups).toHaveLength(1); + expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]); + expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id); + }); + + it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const projects = [ + makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-local"), + title: "t3code", + repositoryIdentity, + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote-with-identity"), + title: "t3code", + workspaceRoot: "/remote/t3code", + repositoryIdentity, + updatedAt: "2026-06-01T00:00:00.000Z", + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote-fresh"), + title: "t3code", + workspaceRoot: "/remote/t3code/", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ]; + + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }); + + expect(scopes).toHaveLength(1); + expect(scopes[0]?.representative.id).toBe(ProjectId.make("project-local")); + expect(scopes[0]?.projects.map((project) => project.id)).toContain( + ProjectId.make("project-remote-fresh"), + ); + expect(scopes[0]?.projectRefs).toHaveLength(3); + }); + + it("sorts v2 project scopes by their grouped thread activity", () => { + const environmentId = EnvironmentId.make("environment-1"); + const olderProject = makeProject({ + environmentId, + id: ProjectId.make("project-older"), + title: "Older project", + }); + const newerProject = makeProject({ + environmentId, + id: ProjectId.make("project-newer"), + title: "Newer project", + }); + const scopes = buildHomeProjectScopes({ + projects: [newerProject, olderProject], + environmentId: null, + projectGroupingMode: "separate", + }); + + expect( + sortHomeProjectScopes({ + scopes, + threads: [ + makeThread({ + environmentId, + id: ThreadId.make("thread-older-project"), + projectId: olderProject.id, + title: "Most recently active", + updatedAt: "2026-06-03T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("thread-newer-project"), + projectId: newerProject.id, + title: "Less recently active", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ], + pendingTasks: [], + projectSortOrder: "updated_at", + }).map((scope) => scope.representative.id), + ).toEqual([olderProject.id, newerProject.id]); + }); + + it("sorts invalid project creation timestamps after valid ones", () => { + const environmentId = EnvironmentId.make("environment-1"); + const invalidProject = makeProject({ + environmentId, + id: ProjectId.make("project-invalid"), + title: "A invalid timestamp", + createdAt: "invalid", + }); + const validProject = makeProject({ + environmentId, + id: ProjectId.make("project-valid"), + title: "Z valid timestamp", + createdAt: "2026-06-02T00:00:00.000Z", + }); + const scopes = buildHomeProjectScopes({ + projects: [invalidProject, validProject], + environmentId: null, + projectGroupingMode: "separate", + }); + + expect( + sortHomeProjectScopes({ + scopes, + threads: [], + pendingTasks: [], + projectSortOrder: "created_at", + }).map((scope) => scope.representative.id), + ).toEqual([validProject.id, invalidProject.id]); + }); + + it("uses the freshest member when a grouped scope has no activity", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const olderMember = makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-older-member"), + title: "t3code", + updatedAt: "2026-06-01T00:00:00.000Z", + repositoryIdentity, + }); + const newerMember = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-newer-member"), + title: "t3code", + updatedAt: "2026-06-03T00:00:00.000Z", + repositoryIdentity, + }); + const otherProject = makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-other"), + title: "other", + updatedAt: "2026-06-02T00:00:00.000Z", + }); + const scopes = buildHomeProjectScopes({ + projects: [olderMember, newerMember, otherProject], + environmentId: null, + projectGroupingMode: "repository", + }); + + expect( + sortHomeProjectScopes({ + scopes, + threads: [], + pendingTasks: [], + projectSortOrder: "updated_at", + })[0]?.key, + ).toBe(scopes.find((scope) => scope.projects.length === 2)?.key); + }); + + it("does not merge unrelated repositories that share a title", () => { + const environmentId = EnvironmentId.make("environment-1"); + const projects = ["one", "two"].map((name) => + makeProject({ + environmentId, + id: ProjectId.make(`project-${name}`), + title: "app", + repositoryIdentity: { + canonicalKey: `github.com/example/${name}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `git@github.com:example/${name}.git`, + }, + }, + }), + ); + + expect( + buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }), + ).toHaveLength(2); + }); + + it("uses the repository label for a singleton repository scope", () => { + const project = makeProject({ + environmentId: EnvironmentId.make("environment-1"), + id: ProjectId.make("project-1"), + title: "local-worktree-name", + repositoryIdentity: { + canonicalKey: "github.com/pingdotgg/t3code", + displayName: "codething-mvp", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }, + }); + + const scopes = buildHomeProjectScopes({ + projects: [project], + environmentId: null, + projectGroupingMode: "repository", + }); + const groups = buildGroups( + [project], + [ + makeThread({ + environmentId: project.environmentId, + id: ThreadId.make("thread-1"), + projectId: project.id, + title: "Thread", + }), + ], + ); + + expect(scopes[0]?.title).toBe("codething-mvp"); + expect(groups[0]?.title).toBe("codething-mvp"); + }); + it("sorts the newest thread first regardless of snapshot order", () => { const environmentId = EnvironmentId.make("environment-1"); const project = makeProject({ diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index cada956d8bb..21084f0f5fe 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,14 +1,20 @@ import { deriveLogicalProjectKey, + derivePhysicalProjectKey, deriveProjectGroupLabel, } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort"; +import { + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, +} from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, + ScopedProjectRef, SidebarProjectGroupingMode, SidebarProjectSortOrder, SidebarThreadSortOrder, @@ -22,6 +28,161 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export type HomeProjectSortOrder = Exclude; +export interface HomeProjectScope { + readonly key: string; + readonly title: string; + readonly representative: EnvironmentProject; + readonly projects: ReadonlyArray; + readonly projectRefs: ReadonlyArray; +} + +function getProjectFreshnessTimestamp(project: EnvironmentProject): number { + return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0; +} + +function getProjectSortTimestamp( + project: EnvironmentProject, + sortOrder: HomeProjectSortOrder, +): number { + return sortOrder === "created_at" + ? (toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY) + : (toSortableTimestamp(project.updatedAt) ?? + toSortableTimestamp(project.createdAt) ?? + Number.NEGATIVE_INFINITY); +} + +export function buildHomeProjectScopes(input: { + readonly projects: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectGroupingMode: SidebarProjectGroupingMode; +}): ReadonlyArray { + const projects = input.projects.filter( + (project) => input.environmentId === null || project.environmentId === input.environmentId, + ); + const projectsByPhysicalKey = new Map(); + for (const project of projects) { + const physicalKey = derivePhysicalProjectKey(project); + const existing = projectsByPhysicalKey.get(physicalKey); + if (existing) existing.push(project); + else projectsByPhysicalKey.set(physicalKey, [project]); + } + + const winnersByPhysicalKey = new Map< + string, + { readonly key: string; readonly project: EnvironmentProject } + >(); + for (const [physicalKey, members] of projectsByPhysicalKey) { + const project = members.reduce((winner, candidate) => { + const freshnessDelta = + getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner); + return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id) + ? candidate + : winner; + }); + const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project; + winnersByPhysicalKey.set(physicalKey, { + key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }), + project, + }); + } + + const groups = new Map(); + for (const { key, project } of winnersByPhysicalKey.values()) { + const existing = groups.get(key); + if (existing) existing.push(project); + else groups.set(key, [project]); + } + + const projectRefsByGroup = new Map(); + const seenProjectRefs = new Set(); + for (const project of projects) { + const refKey = scopedProjectKey(project.environmentId, project.id); + if (seenProjectRefs.has(refKey)) continue; + seenProjectRefs.add(refKey); + + const key = + winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ?? + deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode }); + const refs = projectRefsByGroup.get(key); + const projectRef = { environmentId: project.environmentId, projectId: project.id }; + if (refs) refs.push(projectRef); + else projectRefsByGroup.set(key, [projectRef]); + } + + return Array.from(groups, ([key, projects]) => { + const representative = projects[0]!; + return { + key, + title: deriveProjectGroupLabel({ representative, members: projects }), + representative, + projects, + projectRefs: projectRefsByGroup.get(key) ?? [], + }; + }); +} + +export function sortHomeProjectScopes(input: { + readonly scopes: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly pendingTasks: ReadonlyArray; + readonly projectSortOrder: HomeProjectSortOrder; +}): ReadonlyArray { + const scopeKeyByProjectRef = new Map( + input.scopes.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.key] as const, + ), + ), + ); + const latestActivityByScope = new Map(); + const recordActivity = (scopeKey: string | undefined, timestamp: number) => { + if (!scopeKey || !Number.isFinite(timestamp)) return; + latestActivityByScope.set( + scopeKey, + Math.max(latestActivityByScope.get(scopeKey) ?? Number.NEGATIVE_INFINITY, timestamp), + ); + }; + + for (const thread of input.threads) { + if (thread.archivedAt !== null) continue; + recordActivity( + scopeKeyByProjectRef.get(scopedProjectKey(thread.environmentId, thread.projectId)), + getThreadSortTimestamp(thread, input.projectSortOrder), + ); + } + for (const pendingTask of input.pendingTasks) { + recordActivity( + scopeKeyByProjectRef.get( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), + Date.parse(pendingTask.message.createdAt), + ); + } + + return Arr.sort( + input.scopes, + Order.mapInput( + Order.Struct({ + timestamp: Order.flip(Order.Number), + title: Order.String, + key: Order.String, + }), + (scope: HomeProjectScope) => ({ + timestamp: + latestActivityByScope.get(scope.key) ?? + Math.max( + ...scope.projects.map((project) => + getProjectSortTimestamp(project, input.projectSortOrder), + ), + ), + title: scope.title, + key: scope.key, + }), + ), + ); +} + /** * Default home view only surfaces threads active within this window, to keep the * screen compact while keeping recent work visible. @@ -103,22 +264,18 @@ export function buildHomeThreadGroups(input: { const groups = new Map(); const groupKeyByProjectKey = new Map(); - for (const project of input.projects) { - if (input.environmentId !== null && project.environmentId !== input.environmentId) { - continue; - } - - const groupKey = deriveLogicalProjectKey(project, { - groupingMode: input.projectGroupingMode, + for (const scope of buildHomeProjectScopes(input)) { + groups.set(scope.key, { + key: scope.key, + projects: [...scope.projects], + pendingTasks: [], + threads: [], }); - const physicalKey = scopedProjectKey(project.environmentId, project.id); - groupKeyByProjectKey.set(physicalKey, groupKey); - - const existing = groups.get(groupKey); - if (existing) { - existing.projects.push(project); - } else { - groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] }); + for (const projectRef of scope.projectRefs) { + groupKeyByProjectKey.set( + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.key, + ); } } @@ -186,10 +343,7 @@ export function buildHomeThreadGroups(input: { continue; } - const title = - group.projects.length > 1 - ? deriveProjectGroupLabel({ representative, members: group.projects }) - : representative.title; + const title = deriveProjectGroupLabel({ representative, members: group.projects }); const groupMatches = query.length === 0 || title.toLocaleLowerCase().includes(query) || @@ -215,21 +369,23 @@ export function buildHomeThreadGroups(input: { ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) : sortedThreads; - // Sorted newest-first, so the first thread whose project is a group member - // marks the machine the user last worked on. - const lastActiveProject = Arr.findFirst(sortedThreads, (thread) => - group.projects.some( - (project) => - project.environmentId === thread.environmentId && project.id === thread.projectId, - ), - ).pipe( + // A stale project id still resolves to the canonical member with the same + // environment/path, so quick creation follows the machine with the newest activity. + const lastActiveProject = Arr.head(sortedThreads).pipe( Option.flatMap((thread) => Arr.findFirst( - group.projects, + input.projects, (project) => project.environmentId === thread.environmentId && project.id === thread.projectId, ), ), + Option.flatMap((threadProject) => + Arr.findFirst( + group.projects, + (project) => + derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject), + ), + ), Option.getOrNull, ); diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 84a9f32ee5e..186c606ae8f 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -36,6 +36,8 @@ import { AppText as Text } from "../../components/AppText"; const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; +const COMPACT_ACTION_CIRCLE_SIZE = 28; +const COMPACT_ACTION_ICON_SIZE = 13; export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; export const THREAD_SWIPE_SPRING = { @@ -163,10 +165,20 @@ export function useSwipeableScrollGate(options?: { export function ThreadSwipeable(props: { readonly backgroundColor: ColorValue; readonly children: (close: () => void) => ReactNode; + /** Uses action visuals that fit inside compact 44pt rows. The press target + * still spans the row's full height and width. */ + readonly compactActions?: boolean; readonly containerStyle?: StyleProp; /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; + /** + * What a full swipe commits: "delete" (default, v1 behavior — the Delete + * button stretches) or "primary" — the advertised primary action fires and + * its button stretches instead. A full swipe must always match the action + * the stretching button advertises. + */ + readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; @@ -239,7 +251,11 @@ export function ThreadSwipeable(props: { if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); - props.onDelete(); + if (props.fullSwipeAction === "primary") { + props.primaryAction.onPress(); + } else { + props.onDelete(); + } } }} overshootFriction={1} @@ -247,6 +263,8 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( ["name"]; @@ -281,6 +300,8 @@ function SwipeActionButton(props: { readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; }) { + const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; + const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); @@ -312,7 +333,7 @@ function SwipeActionButton(props: { return { transform: [{ translateX: -stretch }], - width: ACTION_CIRCLE_SIZE + stretch, + width: circleSize + stretch, }; }); const iconStyle = useAnimatedStyle(() => { @@ -374,13 +395,13 @@ function SwipeActionButton(props: { width: "100%", })} > - + - + - + {props.label} @@ -422,6 +443,8 @@ function SwipeActionButton(props: { export function ThreadSwipeActions(props: { readonly backgroundColor: ColorValue; + readonly compact: boolean; + readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeThreshold: number; readonly onDelete: () => void; readonly onFullSwipeArmedChange: (armed: boolean) => void; @@ -430,6 +453,7 @@ export function ThreadSwipeActions(props: { readonly threadTitle: string; readonly translation: SharedValue; }) { + const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; useAnimatedReaction( () => -props.translation.value >= props.fullSwipeThreshold, (armed, previous) => { @@ -452,17 +476,19 @@ export function ThreadSwipeActions(props: { diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8effdc942d9..e200eb7acde 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -6,19 +7,37 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -type ThreadListAction = "archive" | "unarchive" | "delete"; +/** Version skew: never send settle/unsettle to a server that predates them + (capability defaults false on decode for older servers). */ +function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + +type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; + +const ACTION_VERBS: Record = { + archive: "archived", + unarchive: "unarchived", + delete: "deleted", + settle: "settled", + unsettle: "un-settled", +}; function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { const error = Cause.squash(cause); if (error instanceof Error && error.message.trim().length > 0) { return error.message; } - const verb = - action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; - return `The thread could not be ${verb}.`; + return `The thread could not be ${ACTION_VERBS[action]}.`; } function selectionHaptic(): void { @@ -28,54 +47,115 @@ function selectionHaptic(): void { function actionFailureTitle(action: ThreadListAction): string { if (action === "archive") return "Could not archive thread"; if (action === "unarchive") return "Could not unarchive thread"; + if (action === "settle") return "Could not settle thread"; + if (action === "unsettle") return "Could not un-settle thread"; return "Could not delete thread"; } +/** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, ) { const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); + const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( async (action: ThreadListAction, thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { - return; + return false; } inFlightThreadKeys.current.add(key); selectionHaptic(); try { - const mutation = - action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation; - const result = await mutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + if ( + (action === "settle" || action === "unsettle") && + !environmentSupportsSettlement(thread.environmentId) + ) { + Alert.alert( + actionFailureTitle(action), + "This environment's server does not support settling yet. Update the server to use Settle.", + ); + return false; + } + // Settle may only target what effectiveSettled could classify as + // settled: not starting/running sessions, not threads waiting on + // approvals or user input. Anything else would hide live work. + if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { + Alert.alert( + actionFailureTitle(action), + "This thread still needs attention. Resolve or interrupt it first, then try again.", + ); + return false; + } + // Archive keeps its original, narrower guard: never interrupt a + // thread mid-turn. + if ( + action === "archive" && + thread.session?.status === "running" && + thread.session.activeTurnId != null + ) { + Alert.alert( + actionFailureTitle(action), + "This thread is working. Interrupt it first, then try again.", + ); + return false; + } + const result = + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return; + return false; + } + // Settled threads stay in the live shell stream; only the archive + // lifecycle still feeds the archived-snapshot surface. + if (action === "archive" || action === "unarchive" || action === "delete") { + refreshArchivedThreadsForEnvironment(thread.environmentId); } onCompleted?.(action, thread); + return true; } finally { inFlightThreadKeys.current.delete(key); } }, - [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], + [ + archiveMutation, + deleteMutation, + onCompleted, + settleMutation, + unarchiveMutation, + unsettleMutation, + ], ); return executeAction; } function useConfirmDeleteThread( - executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, ) { return useCallback( (thread: EnvironmentThreadShell) => { @@ -111,6 +191,8 @@ function useConfirmDeleteThread( export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly settleThread: (thread: EnvironmentThreadShell) => Promise; + readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -120,10 +202,18 @@ export function useThreadListActions(): { }, [executeAction], ); + const settleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, + [executeAction], + ); + const unsettleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, + [executeAction], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { archiveThread, confirmDeleteThread }; + return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; } export function useArchivedThreadListActions( diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index d1327473b0d..9c068c6249c 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -2,7 +2,8 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, @@ -22,6 +23,7 @@ import { } from "react"; import { useWindowDimensions, View } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; +import { AsyncResult } from "effect/unstable/reactivity"; import { deriveFileInspectorPaneLayout, @@ -34,11 +36,12 @@ import { } from "../../lib/layout"; import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { HomeListOptionsProvider } from "../home/home-list-options"; +import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -184,6 +187,31 @@ export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; }) { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + if (!AsyncResult.isSuccess(preferencesResult)) { + return AsyncResult.isFailure(preferencesResult) ? ( + + ) : null; + } + return ( + + ); +} + +function AdaptiveWorkspaceLayoutContent( + props: { + readonly children: ReactNode; + readonly pathname: string; + } & { + readonly projectGroupingMode: SidebarProjectGroupingMode; + }, +) { + const projectGroupingMode = props.projectGroupingMode; const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -474,7 +502,7 @@ export function AdaptiveWorkspaceLayout(props: { ); return ( - + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 6c67a4d89e8..4c33675f57f 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -119,10 +119,14 @@ function LocalSettingsRouteScreen() { /> + + + + @@ -502,10 +506,14 @@ function ConfiguredSettingsRouteScreen() { /> + + + + @@ -514,6 +522,54 @@ function ConfiguredSettingsRouteScreen() { ); } +function GeneralSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.projectGroupingEnabled !== false + : true; + + return ( + + savePreferences({ projectGroupingEnabled: value })} + /> + + ); +} + +/** + * Device-local beta toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → Beta backed by mobile preferences. + */ +function BetaSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.threadListV2Enabled === true + : false; + + return ( + + + savePreferences({ threadListV2Enabled: value })} + /> + + + One flat thread list in creation order. Active work renders as cards; settled threads + collapse to compact rows. Switch back any time. + + + ); +} + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 4c99c2c866c..2b257ec175c 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,8 +1,9 @@ import * as Haptics from "expo-haptics"; +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Pressable, View } from "react-native"; -import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native"; +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; @@ -10,6 +11,9 @@ import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; +const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); +const AnimatedLiquidGlassView = Animated.createAnimatedComponent(LiquidGlassView); + export function GitActionProgressOverlay(props: { readonly progress: GitActionProgress; readonly onDismiss: () => void; @@ -45,7 +49,7 @@ export function GitActionProgressOverlay(props: { return ( + const glassBorder = useThemeColor("--color-header-border"); + const glassTint = useThemeColor("--color-glass-tint"); + const isDarkMode = useColorScheme() === "dark"; + const content = ( + <> @@ -89,7 +88,56 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { {progress.prUrl ? ( ) : null} - + + ); + + if (isLiquidGlassSupported) { + return ( + + + + {content} + + + + ); + } + + const bgClass = + progress.phase === "error" + ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" + : "bg-card border-border"; + + return ( + + {content} + ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ce81db8c486..e328bcef00e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -582,10 +582,13 @@ export function NewTaskDraftScreen(props: { ? "Approve actions" : flow.runtimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : flow.runtimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:auto", title: "Auto" }, { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e712664d30a..59af7199af7 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -26,7 +26,13 @@ import { type ViewStyle, } from "react-native"; import ImageViewing from "react-native-image-viewing"; -import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { + FadeIn, + FadeInDown, + FadeOut, + FadeOutDown, + LinearTransition, +} from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( const isReconnecting = props.status.kind !== "unavailable"; return ( - + - + ); }); @@ -627,10 +638,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "Approve actions" : currentRuntimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : currentRuntimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:auto", title: "Auto" }, { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 32527b0b719..5cb04290f66 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,21 +14,18 @@ import type { ServerConfig as T3ServerConfig, ThreadId, } from "@t3tools/contracts"; -import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; -import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppText as Text } from "../../components/AppText"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { PendingApproval, PendingUserInput, @@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray Date.now()); - - useEffect(() => { - const intervalId = setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - return () => clearInterval(intervalId); - }, [props.startedAt]); - - const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; - - return ( - - - - - - - - - - Working for {durationLabel} - - - - - ); -}); - export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const insets = useSafeAreaInsets(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; @@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; - const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; + const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a @@ -411,6 +359,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} + activeWorkStartedAt={props.activeWorkStartedAt} listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} @@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - - {props.activeWorkStartedAt ? ( - - ) : null} - + {props.activePendingApproval || props.activePendingUserInput ? ( ) : null} - + ; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; @@ -816,6 +818,10 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "working") { + return ; + } + if (entry.type === "turn-fold") { return ( Date.now()); + + useEffect(() => { + const intervalId = setInterval(() => { + setNowMs(Date.now()); + }, 1_000); + return () => clearInterval(intervalId); + }, [props.startedAt]); + + const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; + + return ( + + + + + + + + Working for {durationLabel} + + + ); +}); + function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; @@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, expandedTurnIds, expandedWorkGroupIds, + props.activeWorkStartedAt, ), - [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], + [ + expandedTurnIds, + expandedWorkGroupIds, + props.activeWorkStartedAt, + props.feed, + props.latestTurn, + ], ); // The empty↔filled key below remounts the list, which resets its imperative @@ -1761,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }} /> - {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? ( + {props.feed.length === 0 && + props.activeWorkStartedAt === null && + props.contentPresentation.kind === "ready" ? ( (null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -186,26 +216,109 @@ function ThreadNavigationSidebarPane( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { - options, - setSelectedEnvironmentId, - setProjectGroupingMode, - setProjectSortOrder, - setThreadSortOrder, - } = useHomeListOptions(availableEnvironmentIds); + const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = + useHomeListOptions(availableEnvironmentIds); + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: options.selectedEnvironmentId, + projectGroupingMode: options.projectGroupingMode, + }), + [options.projectGroupingMode, options.selectedEnvironmentId, projects], + ); + const projectFilterOptions = useMemo( + () => + projectScopes.map((scope) => ({ + key: scope.key, + label: scope.title, + })), + [projectScopes], + ); + const projectTitleByProjectKey = useMemo( + () => + new Map( + projectScopes.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [ + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.title, + ] as const, + ), + ), + ), + [projectScopes], + ); + const selectedProjectScope = useMemo( + () => + selectedProjectKey === null + ? null + : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null), + [projectScopes, selectedProjectKey], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); + const selectedProjectRefs = useMemo( + () => + selectedProjectScope === null + ? null + : new Set( + selectedProjectScope.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), + ), + [selectedProjectScope], + ); + const scopedProjects = useMemo( + () => + selectedProjectRefs === null + ? projects + : projects.filter((project) => + selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), + ), + [projects, selectedProjectRefs], + ); + const scopedThreads = useMemo( + () => + selectedProjectRefs === null + ? threads + : threads.filter((thread) => + selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), + ), + [selectedProjectRefs, threads], + ); + const scopedPendingTasks = useMemo( + () => + selectedProjectRefs === null + ? pendingTasks + : pendingTasks.filter((pendingTask) => + selectedProjectRefs.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), + ), + [pendingTasks, selectedProjectRefs], + ); const groups = useMemo( () => buildHomeThreadGroups({ - projects, - threads, - pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), - [options, pendingTasks, projects, props.searchQuery, threads], + [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap @@ -237,6 +350,180 @@ function ThreadNavigationSidebarPane( } return map; }, [projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [projects]); + + // Thread List v2 (beta) support — same model as the compact Home list + // (HomeScreen.tsx): flat creation-order card block + settled recency tail. + // PR states stream in per-row; merged/closed PRs auto-settle their thread + // on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // now ticks per minute so the inactivity auto-settle boundary is actually + // crossed while the pane stays open; without a clock dependency the + // partition memoizes a frozen "now". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + useEffect(() => { + if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; + return buildThreadListV2Items({ + threads: threads.filter((thread) => thread.archivedAt === null), + environmentId: options.selectedEnvironmentId, + projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + snoozeEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), + }); + }, [ + changeRequestStateByKey, + nowMinute, + snoozeWakeTick, + options.selectedEnvironmentId, + props.searchQuery, + settledVisibleCount, + settlementEnvironmentIds, + snoozeEnvironmentIds, + threadListV2Enabled, + threads, + selectedProjectScope, + ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); + const listItems = useMemo(() => { + if (!threadListV2Enabled) return listLayout.items; + // Queued offline tasks render above the thread rows (mirrors the + // compact Home v2 list): they are not thread shells, so the v2 item + // builder never sees them, but they must stay visible and deletable + // while their environment is offline. Same environment scope and + // search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = pendingTasks.filter( + (pendingTask) => + (options.selectedEnvironmentId === null || + pendingTask.message.environmentId === options.selectedEnvironmentId) && + (selectedProjectRefs === null || + selectedProjectRefs.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + )) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ + type: "v2-pending-task" as const, + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + isLast: index === v2PendingTasks.length - 1, + })); + for (const item of threadListV2Layout.items) { + items.push({ + type: "v2-thread" as const, + key: scopedThreadKey(item.thread.environmentId, item.thread.id), + item, + }); + } + if (threadListV2Layout.hiddenSettledCount > 0) { + items.push({ + type: "v2-show-more", + key: "v2-show-more", + hiddenCount: threadListV2Layout.hiddenSettledCount, + }); + } + return items; + }, [ + listLayout.items, + options.selectedEnvironmentId, + pendingTasks, + props.searchQuery, + selectedProjectRefs, + threadListV2Enabled, + threadListV2Layout, + ]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ @@ -260,36 +547,54 @@ function ThreadNavigationSidebarPane( })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: options.projectSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: options.threadSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - subtitle: option.subtitle, - state: options.projectGroupingMode === option.value ? "on" : "off", - })), - }, + ...(projectFilterOptions.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + subtitle: "Show threads from every project", + state: selectedProjectKey === null ? "on" : "off", + }, + ...projectFilterOptions.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + })), + ], + }, + ] satisfies MenuAction[])), + // v2 lays the list out in fixed creation order — offering sort/group + // controls it silently ignores would be a lie. Environment still + // scopes the v2 partition, so it stays. + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: options.projectSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: options.threadSortOrder === option.value ? "on" : "off", + })), + }, + ] satisfies MenuAction[])), ], - [environments, options], + [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -305,6 +610,17 @@ function ThreadNavigationSidebarPane( if (environment) setSelectedEnvironmentId(environment.environmentId); return; } + if (event === "project:all") { + setSelectedProjectKey(null); + return; + } + if (event.startsWith("project:")) { + const projectKey = event.slice("project:".length); + if (projectFilterOptions.some((project) => project.key === projectKey)) { + setSelectedProjectKey(projectKey); + } + return; + } const projectSort = PROJECT_SORT_OPTIONS.find( (option) => `project-sort:${option.value}` === event, ); @@ -319,14 +635,10 @@ function ThreadNavigationSidebarPane( setThreadSortOrder(threadSort.value); return; } - const grouping = PROJECT_GROUPING_OPTIONS.find( - (option) => `project-grouping:${option.value}` === event, - ); - if (grouping) setProjectGroupingMode(grouping.value); }, [ environments, - setProjectGroupingMode, + projectFilterOptions, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, @@ -382,7 +694,44 @@ function ThreadNavigationSidebarPane( onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - const listExtraData = props.selectedThreadKey ?? ""; + const listExtraData = useMemo( + () => ({ + selectedThreadKey: props.selectedThreadKey ?? "", + savedConnectionsById, + serverConfigs, + }), + [props.selectedThreadKey, savedConnectionsById, serverConfigs], + ); + const sidebarItemsAreEqual = useCallback( + (previous: SidebarListItem, item: SidebarListItem): boolean => { + if (previous.type === "v2-thread" && item.type === "v2-thread") { + return ( + previous.key === item.key && + previous.item.thread === item.item.thread && + previous.item.variant === item.item.variant && + previous.item.showSettledDivider === item.item.showSettledDivider + ); + } + if (previous.type === "v2-show-more" && item.type === "v2-show-more") { + return previous.hiddenCount === item.hiddenCount; + } + if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { + return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; + } + if ( + previous.type === "v2-thread" || + previous.type === "v2-show-more" || + previous.type === "v2-pending-task" || + item.type === "v2-thread" || + item.type === "v2-show-more" || + item.type === "v2-pending-task" + ) { + return false; + } + return homeListItemsAreEqual(previous, item); + }, + [], + ); const focusSearch = useCallback(() => { const focus = () => { if (props.nativeChrome) { @@ -401,8 +750,79 @@ function ThreadNavigationSidebarPane( }, [props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( - ({ item }: { readonly item: HomeListItem }) => { + ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { + case "v2-pending-task": + return ( + + ); + case "v2-thread": { + const thread = item.item.thread; + const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + return ( + + provider.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + )?.driver ?? null + } + environmentLabel={ + Object.keys(savedConnectionsById).length > 1 + ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null + } + pane="sidebar" + selected={ + scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey + } + fullSwipeWidth={props.width - 20} + onSelectThread={handleSelectThread} + onDeleteThread={confirmDeleteThread} + onArchiveThread={archiveThread} + settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={projectCwdByKey.get(scopeKey) ?? null} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={sidebarScrollGesture} + /> + ); + } + case "v2-show-more": + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({item.hiddenCount} settled hidden) + + + ); case "header": return ( buildHomeListFilterMenu({ environments, + projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, + selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, onEnvironmentChange: setSelectedEnvironmentId, + onProjectChange: setSelectedProjectKey, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, - onProjectGroupingModeChange: setProjectGroupingMode, + listOrganization: !threadListV2Enabled, }), [ environments, options, - setProjectGroupingMode, + projectFilterOptions, + selectedProjectKey, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, + threadListV2Enabled, ], ); const nativeHeaderItems = useMemo( @@ -525,13 +962,28 @@ function ThreadNavigationSidebarPane( }), [filterIcon, filterMenu, props.onOpenSettings], ); + // "No threads yet" over an inbox that is merely all-snoozed reads as + // data loss; name the snoozed threads instead. + const snoozedCount = threadListV2Layout.snoozedCount; const listEmpty = ( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 - ? "No matching threads" - : "No threads yet"} + ? snoozedCount > 0 + ? // Snoozed matches passed this same search filter — "No + // matching threads" would misreport them as nonexistent. + snoozedCount === 1 + ? "1 matching thread snoozed" + : "All matching threads snoozed" + : "No matching threads" + : snoozedCount > 0 + ? snoozedCount === 1 + ? "1 thread snoozed" + : `${snoozedCount} threads snoozed` + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); @@ -539,6 +991,7 @@ function ThreadNavigationSidebarPane( return ( <> item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} @@ -625,12 +1078,12 @@ function ThreadNavigationSidebarPane( item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} contentContainerStyle={[ diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx new file mode 100644 index 00000000000..69729cb6469 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -0,0 +1,464 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { MenuAction } from "@react-native-menu/menu"; +import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; +import { Platform, Pressable, useWindowDimensions, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadPr } from "../../state/use-thread-pr"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; + +/** + * Thread List v2 renders one flat native list: rich edge-to-edge rows for + * active work and a receded settled tail, all with native swipe and + * long-press actions. State reads through colored status labels and text + * hierarchy rather than card fills. + */ + +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + +// Status hues follow the system-wide convention set by sidebar v1 and the +// Live Activity/widgets (amber approval, indigo input, sky working) so a +// thread reads the same color everywhere it surfaces. +const STATUS_LABEL_BY_STATUS: Partial< + Record +> = { + approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, + input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, + working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, + failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, +}; + +function threadTimeLabel(thread: EnvironmentThreadShell): string { + return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); +} + +// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps +// its own surface (thread screen / settings) rather than crowding the row. +const CARD_MENU_ACTIONS: MenuAction[] = [ + { id: "settle", title: "Settle", image: "checkmark" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +const SLIM_MENU_ACTIONS: MenuAction[] = [ + { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +// Pre-settlement servers: no lifecycle items, archive fills the gap. +const LEGACY_MENU_ACTIONS: MenuAction[] = [ + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +/** Rounded-row radius shared with the v1 sidebar rows. */ +const SIDEBAR_V2_ROW_RADIUS = 12; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: { + readonly pane?: "screen" | "sidebar"; +}) { + const borderColor = useThemeColor("--color-border"); + return ( + + Settled + + + ); +}); + +export const ThreadListV2Row = memo(function ThreadListV2Row(props: { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + readonly showSettledDivider: boolean; + readonly project: EnvironmentProject | null; + readonly projectTitle?: string; + readonly providerDriver: string | null; + /** Which machine hosts the thread. Null when only one environment is + connected — repeating the same label on every row is noise. Mirrors + the web sidebar's remote-environment cloud icon, but as text since + phones have no hover tooltips. */ + readonly environmentLabel: string | null; + /** Hosting surface. "screen" (default) renders the compact Home idiom: + flat edge-to-edge rows on the screen background with inset hairlines. + "sidebar" renders the iPad split-view idiom: rounded rows blending + into the drawer surface, selection filled with the accent color — + matching the v1 sidebar rows. */ + readonly pane?: "screen" | "sidebar"; + /** Highlights the thread open in the detail pane (iPad split view). The + compact Home list never sets it — phones navigate away on select. */ + readonly selected?: boolean; + /** Override for narrow panes (iPad sidebar); defaults to window width. */ + readonly fullSwipeWidth?: number; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + /** False on environments whose server predates thread.settle/unsettle: + swipe + menu fall back to Archive instead of failing on use. */ + readonly settlementSupported: boolean; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** Reports this row's live PR state up so the partition can auto-settle + merged/closed work (mirrors web's onChangeRequestState). */ + readonly onChangeRequestState?: ( + threadKey: string, + state: "open" | "closed" | "merged" | null, + ) => void; + readonly projectCwd?: string | null; + readonly simultaneousSwipeGesture?: ComponentProps< + typeof ThreadSwipeable + >["simultaneousWithExternalGesture"]; +}) { + const { width: windowWidth } = useWindowDimensions(); + const { + thread, + variant, + onSelectThread, + onDeleteThread, + onSettleThread, + onUnsettleThread, + onArchiveThread, + onChangeRequestState, + } = props; + + const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); + const prState = pr?.state ?? null; + const threadKey = `${thread.environmentId}:${thread.id}`; + useEffect(() => { + onChangeRequestState?.(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const screenColor = useThemeColor("--color-screen"); + const drawerColor = useThemeColor("--color-drawer"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const sidebarPane = props.pane === "sidebar"; + const selected = props.selected === true; + + const status = resolveThreadListV2Status(thread); + const statusLabel = STATUS_LABEL_BY_STATUS[status]; + const timeLabel = threadTimeLabel(thread); + + const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); + const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); + const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "settle") handleSettle(); + if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "delete") handleDelete(); + }, + [handleArchive, handleDelete, handleSettle, handleUnsettle], + ); + + // Swipe: the v2 primary action is the lifecycle transition. Every settled + // row can un-settle — explicit settles clear the override, auto-settled + // rows get pinned active until real activity clears the pin. + const canUnsettle = variant === "slim"; + const primaryAction = useMemo(() => { + // Pre-settlement server: archive is the swipe action, as in v1. (Slim + // rows cannot occur here — unsupported environments never classify as + // settled.) + if (!props.settlementSupported) { + return { + accessibilityLabel: `Archive ${thread.title}`, + icon: "archivebox" as const, + label: "Archive", + onPress: handleArchive, + }; + } + return canUnsettle + ? { + accessibilityLabel: `Un-settle ${thread.title}`, + icon: "arrow.uturn.backward" as const, + label: "Un-settle", + onPress: handleUnsettle, + } + : { + accessibilityLabel: `Settle ${thread.title}`, + icon: "checkmark" as const, + label: "Settle", + onPress: handleSettle, + }; + }, [ + canUnsettle, + handleArchive, + handleSettle, + handleUnsettle, + props.settlementSupported, + thread.title, + ]); + + // The sidebar pane fills selected rows with the accent color (matching the + // v1 sidebar), so every piece of row text needs a white-on-accent variant. + const cardContent = ( + <> + + {props.project ? ( + + ) : null} + + {props.projectTitle ?? props.project?.title ?? ""} + + + {statusLabel?.label ?? timeLabel} + + + + {thread.title} + + + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch || props.environmentLabel ? ( + /* "branch · machine" share one truncating line. The machine sits + last so a tight fit cuts the repetitive label, not the branch — + and machine-only fills the row for non-git projects. */ + + {thread.branch ? ( + + {thread.branch} + + ) : null} + {thread.branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + ) : ( + + )} + {pr ? ( + + #{pr.label} + + ) : null} + {props.providerDriver ? ( + + + + ) : null} + + + ); + + const rowContent = (close: () => void) => + variant === "card" ? ( + { + close(); + onSelectThread(thread); + }} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } + > + {sidebarPane ? ( + cardContent + ) : ( + /* Flat native list rows: no tonal containers — colored status + labels and text hierarchy carry state, an inset hairline + separates rows. The opaque screen background stays so swipe + actions reveal behind the row. */ + + {cardContent} + + + )} + + ) : ( + { + close(); + onSelectThread(thread); + }} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } + > + {/* Settled history recedes: dimmed favicon + muted title. */} + + {props.project ? ( + + + + ) : null} + + {thread.title} + + + {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + + + + ); + + return ( + <> + {props.showSettledDivider ? : null} + + {(close) => ( + + {rowContent(close)} + + )} + + + ); +}); diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 902a4ee8b5d..0896335ea96 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -108,7 +108,8 @@ export function ThreadWorkLog(props: { {rows.map((row) => { const expanded = props.expandedRows[row.id] ?? false; - const canExpand = row.fullDetail !== null; + const canExpand = row.canExpand; + const fullDetail = expanded ? row.getFullDetail() : null; const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; @@ -133,7 +134,7 @@ export function ThreadWorkLog(props: { props.onToggleRow(row.id); } }} - onLongPress={() => props.onCopyRow(row.id, row.copyText)} + onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} style={({ pressed }) => ({ backgroundColor: pressed ? pressedBackground : "transparent", })} @@ -204,7 +205,7 @@ export function ThreadWorkLog(props: { - {expanded && row.fullDetail ? ( + {fullDetail ? ( - {row.fullDetail} + {fullDetail} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts new file mode 100644 index 00000000000..e62b9ceda34 --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -0,0 +1,322 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadListV2Items, + resolveThreadListV2Status, + sortThreadsForListV2, +} from "./threadListV2"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeThread( + input: Partial & Pick, +): EnvironmentThreadShell { + return { + environmentId, + projectId: ProjectId.make("project-1"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +const NOW = "2026-06-02T00:00:00.000Z"; + +describe("resolveThreadListV2Status", () => { + it("prioritizes approval over a running session", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + hasPendingApprovals: true, + session: { + threadId: ThreadId.make("t"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + expect(resolveThreadListV2Status(thread)).toBe("approval"); + }); + + it("resolves ready for quiescent threads", () => { + expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( + "ready", + ); + }); +}); + +describe("sortThreadsForListV2", () => { + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForListV2([ + { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); +}); + +describe("buildThreadListV2Items", () => { + it("hides snoozed threads and counts them — visibility parity with web", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("woken"), + title: "Woken", + // Wake time already passed: back in the active list. + snoozedUntil: "2026-06-01T18:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + // Same createdAt → static sort tiebreaks by id; the point is the woken + // thread is BACK in the card block and the snoozed one is gone. + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); + expect(layout.snoozedCount).toBe(1); + }); + + it("classifies snooze with the second-precise clock and reports the next wake", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("just-woke"), + title: "Just woke", + // Woke 30s ago: hidden under the minute-floored clock, visible + // under the precise one. + snoozedUntil: "2026-06-02T00:00:30.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("still-snoozed"), + title: "Still snoozed", + snoozedUntil: "2026-06-02T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + // Minute-floored partition clock vs precise snooze clock. + now: "2026-06-02T00:01:00.000Z", + snoozeNow: "2026-06-02T00:01:07.500Z", + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); + expect(layout.snoozedCount).toBe(1); + expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); + }); + + it("keeps snoozed threads visible on environments without the snooze capability", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + snoozeEnvironmentIds: new Set(), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]); + expect(layout.snoozedCount).toBe(0); + }); + + it("partitions settled threads into a slim tail with one divider", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("settled-2"), + title: "Settled 2", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["active", "card"], + ["settled", "slim"], + ["settled-2", "slim"], + ]); + expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); + expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + }); + + it("keeps cards in creation order while settled sorts by recency", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("older-created"), + title: "Older", + createdAt: "2026-06-01T08:00:00.000Z", + updatedAt: NOW, // recent activity must NOT promote it + }), + makeThread({ + id: ThreadId.make("newer-created"), + title: "Newer", + createdAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); + }); + + it("keeps settled threads in the tail and filters by search query", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), + makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Fix login again", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "login", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["match", "card"], + ["settled", "slim"], + ]); + }); + + it("scopes the flat list to one project", () => { + const otherProjectId = ProjectId.make("project-2"); + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("included"), title: "Included" }), + makeThread({ + id: ThreadId.make("excluded"), + projectId: otherProjectId, + title: "Excluded", + }), + ], + environmentId: null, + projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }], + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["included"]); + }); + + it("scopes the flat list to every environment member of a logical project", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("local"), title: "Local" }), + makeThread({ + environmentId: remoteEnvironmentId, + id: ThreadId.make("remote"), + title: "Remote", + }), + ], + environmentId: null, + projectRefs: [ + { environmentId, projectId: ProjectId.make("project-1") }, + { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") }, + ], + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]); + }); +}); + +describe("buildThreadListV2Items settled paging", () => { + it("caps the settled tail at settledLimit and reports the hidden count", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + ...Array.from({ length: 4 }, (_, index) => + makeThread({ + id: ThreadId.make(`settled-${index}`), + title: `Settled ${index}`, + settledOverride: "settled", + settledAt: NOW, + latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, + // A turn adopted the message (same requestedAt): without it the + // thread reads as a queued turn start, which never settles. + latestTurn: { + turnId: TurnId.make(`turn-${index}`), + state: "completed", + requestedAt: `2026-06-01T0${index}:00:00.000Z`, + startedAt: `2026-06-01T0${index}:00:00.000Z`, + completedAt: `2026-06-01T0${index}:10:00.000Z`, + assistantMessageId: null, + }, + }), + ), + ]; + + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + settledLimit: 2, + now: NOW, + }); + + expect(layout.hiddenSettledCount).toBe(2); + expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); + // Most recent settled first — the hidden ones are the oldest. + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "active", + "settled-3", + "settled-2", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts new file mode 100644 index 00000000000..efa68153a3f --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -0,0 +1,210 @@ +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +/** + * Thread List v2 model, ported from the web sidebar v2 + * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). + * + * Four visual states, three colors: color is reserved for "act now" + * (approval), "in motion" (working), and "broken" (failed). Ready is the + * unlabeled resting state. + */ +export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; + +// Settled-tail paging: recent history is the common lookup; the deep tail +// stays behind an explicit Show more. Shared by the compact Home list and +// the iPad sidebar so both page identically. +export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; +export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; + +export function resolveThreadListV2Status( + thread: Pick, +): ThreadListV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.hasPendingUserInput) { + return "input"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not + poison the whole ordering, so it sinks to the epoch instead. */ +function parseTimestampMs(isoDate: string): number { + const parsed = Date.parse(isoDate); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** First VALID timestamp wins: a present-yet-malformed string falls through + to the next candidate rather than sinking the row to the epoch. */ +function firstValidTimestampMs(...candidates: ReadonlyArray): number { + for (const candidate of candidates) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +/** + * v2 sort: static creation order, newest thread on top. Activity NEVER + * reorders the list — a row holds its position from open until settled, so + * the screen only moves at lifecycle transitions. Mirrors web's + * sortThreadsForSidebarV2. + */ +export function sortThreadsForListV2( + threads: readonly T[], +): T[] { + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 + // change-by-copy array methods. + return [...threads].sort( + (left, right) => + parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + left.id.localeCompare(right.id), + ); +} + +export interface ThreadListV2Item { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + /** First settled row after the card block draws the SETTLED divider. */ + readonly showSettledDivider: boolean; + readonly isLast: boolean; +} + +export interface ThreadListV2Layout { + readonly items: ThreadListV2Item[]; + /** Settled threads beyond the render limit (behind "Show more"). */ + readonly hiddenSettledCount: number; + /** Snoozed threads hidden from the list (visibility parity with web's + collapsed Snoozed shelf; mobile has no shelf UI yet). */ + readonly snoozedCount: number; + /** Soonest wake time among hidden snoozed threads, or null. Callers arm + a timeout at this boundary so the list re-partitions the moment a + snooze expires instead of on the next minute tick. */ + readonly nextSnoozeWakeAt: string | null; +} + +/** + * Partitions visible threads into the active card block (creation order) and + * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` + * mirrors the web default of 3 — mobile has no client-settings sync yet, so + * the default is fixed here rather than user-configurable. + */ +export function buildThreadListV2Items(input: { + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRefs?: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + }> | null; + readonly searchQuery: string; + /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ + readonly changeRequestStateByKey?: ReadonlyMap; + /** Environments whose server supports thread.settle/unsettle. Threads on + other environments never classify as settled — the user could neither + un-settle nor pin them. Absent = no gating (tests). */ + readonly settlementEnvironmentIds?: ReadonlySet; + /** Environments whose server supports thread.snooze/unsnooze. Same + contract as settlementEnvironmentIds. */ + readonly snoozeEnvironmentIds?: ReadonlySet; + readonly autoSettleAfterDays?: number; + /** Max settled rows to render; the rest are counted, not built. */ + readonly settledLimit?: number; + /** Injectable for tests; defaults to now. */ + readonly now?: string; + /** Second-precise clock for snooze classification. Callers pass a + minute-quantized `now` for memoization; snooze wake times are + second-precise, so classifying with the floored minute would hold a + woken thread hidden for up to a minute. Defaults to `now`. */ + readonly snoozeNow?: string; +}): ThreadListV2Layout { + const now = input.now ?? new Date().toISOString(); + const snoozeNow = input.snoozeNow ?? now; + const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const query = input.searchQuery.trim().toLocaleLowerCase(); + const projectKeys = input.projectRefs + ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) + : null; + + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + let snoozedCount = 0; + let nextSnoozeWakeAt: string | null = null; + for (const thread of input.threads) { + // Callers pass live (unarchived) shells; settled threads are among them + // and partition into the tail via effectiveSettled. + if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; + if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { + continue; + } + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; + const changeRequestState = + input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + // Visibility parity with web: a snoozed thread leaves the list until it + // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed + // work). Snooze outranks settled classification, same as web. + if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + snoozedCount += 1; + if ( + thread.snoozedUntil != null && + (nextSnoozeWakeAt === null || + parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt)) + ) { + nextSnoozeWakeAt = thread.snoozedUntil; + } + continue; + } + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + + const orderedActive = sortThreadsForListV2(active); + const orderedSettled = [...settled].sort( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ); + const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; + const visibleSettled = + orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; + + const items: ThreadListV2Item[] = []; + for (const thread of orderedActive) { + items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); + } + for (const [index, thread] of visibleSettled.entries()) { + items.push({ + thread, + variant: "slim", + showSettledDivider: index === 0, + isLast: false, + }); + } + const last = items.at(-1); + if (last) { + items[items.length - 1] = { ...last, isLast: true }; + } + return { + items, + hiddenSettledCount: orderedSettled.length - visibleSettled.length, + snoozedCount, + nextSnoozeWakeAt, + }; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 8cea5df2307..ab4311524ce 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,6 +38,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..5d323516dd4 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,6 +50,8 @@ function makeThread( checkpoints: [], session: null, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } @@ -159,20 +161,22 @@ describe("buildThreadFeed", () => { return; } - expect(group.activities).toEqual([ - { - id: "tool-completed", - createdAt: "2026-04-01T00:00:02.000Z", - turnId: "turn-1", - summary: "Run tests", - detail: "bun run test", - fullDetail: "/bin/zsh -lc 'bun run test'", - copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", - icon: "command", - toolLike: true, - status: "success", - }, - ]); + expect(group.activities).toHaveLength(1); + expect(group.activities[0]).toMatchObject({ + id: "tool-completed", + createdAt: "2026-04-01T00:00:02.000Z", + turnId: "turn-1", + summary: "Run tests", + detail: "bun run test", + canExpand: true, + icon: "command", + toolLike: true, + status: "success", + }); + expect(group.activities[0]?.getFullDetail()).toBe("/bin/zsh -lc 'bun run test'"); + expect(group.activities[0]?.getCopyText()).toBe( + "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", + ); }); it("keeps MCP inputs available to expanded mobile work rows", () => { @@ -221,8 +225,55 @@ describe("buildThreadFeed", () => { } expect(group.activities[0]?.icon).toBe("wrench"); - expect(group.activities[0]?.fullDetail).toContain('"query": "work log"'); - expect(group.activities[0]?.fullDetail).toContain("repository.search"); + expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); + expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); + }); + + it("defers large tool output expansion until a work row is opened or copied", () => { + let serializedToolOutputs = 0; + const activities = Array.from({ length: 5_000 }, (_, index) => + makeActivity({ + id: EventId.make(`large-tool-${index}`), + kind: "tool.completed", + tone: "tool", + summary: `Tool ${index}`, + createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(), + payload: { + title: `Tool ${index}`, + itemType: "mcp_tool_call", + status: "completed", + data: { + item: { + toJSON: () => { + serializedToolOutputs += 1; + return { output: "x".repeat(32_768) }; + }, + }, + }, + }, + }), + ); + const thread = makeThread({ + id: ThreadId.make("thread-large-tools"), + projectId: ProjectId.make("project-1"), + title: "Large tools", + activities, + }); + + const feed = buildThreadFeed(thread); + expect(serializedToolOutputs).toBe(0); + + const group = feed[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities).toHaveLength(5_000); + expect(group.activities[0]?.getFullDetail()).toContain('"output"'); + expect(serializedToolOutputs).toBe(1); + expect(group.activities[0]?.getCopyText()).toContain('"output"'); + expect(serializedToolOutputs).toBe(1); }); it("folds settled turn work while leaving the terminal answer visible", () => { @@ -412,6 +463,20 @@ describe("buildThreadFeed", () => { }); }); + it("appends active work as a normal timeline row", () => { + const startedAt = "2026-04-01T00:00:01.000Z"; + const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); + + expect(presented).toEqual([ + { + type: "working", + id: "working-indicator-row", + createdAt: startedAt, + }, + ]); + expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]); + }); + it("models work-log overflow as list rows", () => { const activity = ( id: string, @@ -423,8 +488,9 @@ describe("buildThreadFeed", () => { turnId: null, summary: `Tool ${id}`, detail: null, - fullDetail: null, - copyText: id, + canExpand: false, + getFullDetail: () => null, + getCopyText: () => id, icon: "command", toolLike: true, status, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..0c79217502d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -36,8 +36,9 @@ export interface ThreadFeedActivity { readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; - readonly fullDetail: string | null; - readonly copyText: string; + readonly canExpand: boolean; + readonly getFullDetail: () => string | null; + readonly getCopyText: () => string; readonly icon: | "agent" | "alert" @@ -98,6 +99,11 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | Extract + | { + readonly type: "working"; + readonly id: string; + readonly createdAt: string; + } | { readonly type: "activity-group"; readonly id: string; @@ -549,6 +555,27 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { return blocks.length > 0 ? blocks.join("\n\n") : null; } +function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { + return ( + (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || + Boolean((entry.rawCommand ?? entry.command)?.trim()) || + Boolean(entry.detail?.trim()) || + (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) + ); +} + +function memoizeValue(build: () => T): () => T { + let value: T; + let initialized = false; + return () => { + if (!initialized) { + value = build(); + initialized = true; + } + return value; + }; +} + function workEntryPreview( workEntry: Pick, ): string | null { @@ -1105,9 +1132,11 @@ export function deriveThreadFeedPresentation( latestTurn: ThreadFeedLatestTurn | null, expandedTurnIds: ReadonlySet, expandedWorkGroupIds: ReadonlySet = new Set(), + activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", + (entry) => + entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", ); const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); const collapsedEntryIds = new Set(); @@ -1136,12 +1165,19 @@ export function deriveThreadFeedPresentation( appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); } } + if (activeWorkStartedAt !== null) { + result.push({ + type: "working", + id: "working-indicator-row", + createdAt: activeWorkStartedAt, + }); + } return result; } function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, ): void { if (entry.type !== "activity-group") { @@ -1339,7 +1375,14 @@ export function buildThreadFeed( .map((entry) => { const summary = workEntryHeading(entry); const detail = workEntryPreview(entry); - const fullDetail = buildWorkEntryExpandedBody(entry); + const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); + const getCopyText = memoizeValue(() => + [summary, detail, getFullDetail()] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + ); return { type: "activity", id: entry.id, @@ -1351,13 +1394,10 @@ export function buildThreadFeed( turnId: entry.turnId, summary, detail, - fullDetail, + canExpand: workEntryHasExpandedBody(entry), + getFullDetail, + getCopyText, icon: workEntryIcon(entry), - copyText: [summary, detail, fullDetail] - .filter((value, index, values): value is string => { - return Boolean(value) && values.indexOf(value) === index; - }) - .join("\n"), toolLike: workLogEntryIsToolLike(entry), status: workEntryStatus(entry), }, diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 8a8c355b760..78c87119512 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -142,6 +142,13 @@ function stabilizeOptionFunctions( export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; + /** + * Causes dynamic native header factories to be reapplied when their closed-over + * menu content changes. Factory functions are intentionally stabilized, so + * their source alone cannot capture a menu that was initially empty while + * asynchronous data was loading. + */ + readonly optionsVersion?: unknown; readonly listeners?: Record void>; readonly name?: string; }) { @@ -163,7 +170,7 @@ export function NativeStackScreenOptions(props: { if (!navigation || !stableOptions) { return; } - const signature = optionsSignature(stableOptions); + const signature = optionsSignature([stableOptions, props.optionsVersion]); // Avoid re-entering navigation state when semantically equal options are // reapplied every layout (common when callers pass unstable object literals). if (lastAppliedOptionsSignatureRef.current === signature) { @@ -171,7 +178,7 @@ export function NativeStackScreenOptions(props: { } lastAppliedOptionsSignatureRef.current = signature; navigation.setOptions(stableOptions); - }, [navigation, stableOptions]); + }, [navigation, props.optionsVersion, stableOptions]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6971570b0e6..4e576bb2fe1 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -22,6 +22,13 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + readonly projectGroupingEnabled?: boolean; + /** + * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no + * client-settings sync, so the flat v2 thread list is opted into per + * device. + */ + readonly threadListV2Enabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -71,6 +78,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { codeWordBreak?: boolean; connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; + projectGroupingEnabled?: boolean; + threadListV2Enabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -97,6 +106,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { (key): key is string => typeof key === "string", ); } + if (typeof parsed.projectGroupingEnabled === "boolean") { + preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; + } + if (typeof parsed.threadListV2Enabled === "boolean") { + preferences.threadListV2Enabled = parsed.threadListV2Enabled; + } return preferences; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d369fbc4377..8e340ef33bd 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,6 +58,10 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + snoozedUntil: thread.snoozedUntil ?? null, + snoozedAt: thread.snoozedAt ?? null, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/package.json b/apps/server/package.json index d0903c77d75..6be498e803d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,7 +31,8 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", - "node-pty": "^1.1.0" + "node-pty": "^1.1.0", + "yaml": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 00b6c4cfcce..517f633577c 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -12,7 +12,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { DEVELOPMENT_ICON_OVERRIDES, - PUBLISH_ICON_OVERRIDES, + resolveWebAssetBrandForPackageVersion, + resolveWebIconOverrides, } from "../../../scripts/lib/brand-assets.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; @@ -81,50 +82,34 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan } }); -interface PublishIconBackup { - readonly targetPath: string; - readonly backupPath: string; -} - -const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(function* ( +const preparePublishIcons = Effect.fn("preparePublishIcons")(function* ( repoRoot: string, serverDir: string, + version: string, ) { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const backups: PublishIconBackup[] = []; - - for (const override of PUBLISH_ICON_OVERRIDES) { - const sourcePath = path.join(repoRoot, override.sourceRelativePath); - const targetPath = path.join(serverDir, override.targetRelativePath); - const backupPath = `${targetPath}.publish-bak`; - - if (!(yield* fs.exists(sourcePath))) { - return yield* new ServerCliPublishIconSourceMissingError({ sourcePath }); + const brand = resolveWebAssetBrandForPackageVersion(version); + const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({ + sourcePath: path.join(repoRoot, override.sourceRelativePath), + targetPath: path.join(serverDir, override.targetRelativePath), + })); + + for (const icon of icons) { + if (!(yield* fs.exists(icon.sourcePath))) { + return yield* new ServerCliPublishIconSourceMissingError({ sourcePath: icon.sourcePath }); } - if (!(yield* fs.exists(targetPath))) { - return yield* new ServerCliPublishIconTargetMissingError({ targetPath }); + if (!(yield* fs.exists(icon.targetPath))) { + return yield* new ServerCliPublishIconTargetMissingError({ targetPath: icon.targetPath }); } - - yield* fs.copyFile(targetPath, backupPath); - yield* fs.copyFile(sourcePath, targetPath); - backups.push({ targetPath, backupPath }); } - yield* Effect.log("[cli] Applied publish icon overrides to dist/client"); - return backups as ReadonlyArray; -}); - -const restorePublishIconOverrides = Effect.fn("restorePublishIconOverrides")(function* ( - backups: ReadonlyArray, -) { - const fs = yield* FileSystem.FileSystem; - for (const backup of backups) { - if (!(yield* fs.exists(backup.backupPath))) { - continue; - } - yield* fs.rename(backup.backupPath, backup.targetPath); - } + return yield* Effect.forEach(icons, (icon) => + Effect.all({ + original: fs.readFile(icon.targetPath), + publish: fs.readFile(icon.sourcePath), + }).pipe(Effect.map((contents) => ({ ...icon, ...contents }))), + ); }); const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( @@ -236,7 +221,6 @@ const publishCmd = Command.make( const repoRoot = yield* RepoRoot; const serverDir = path.join(repoRoot, "apps/server"); const packageJsonPath = path.join(serverDir, "package.json"); - const backupPath = `${packageJsonPath}.bak`; // Assert build assets exist for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { @@ -247,7 +231,7 @@ const publishCmd = Command.make( } yield* Effect.acquireUseRelease( - // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts + // Acquire: resolve publish metadata and read every original before mutation. Effect.gen(function* () { const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); const workspaceConfig = yield* readWorkspaceConfig(); @@ -273,19 +257,22 @@ const publishCmd = Command.make( ), }; - const original = yield* fs.readFileString(packageJsonPath); - const packageJsonString = yield* encodePackageJson(pkg); - yield* fs.writeFileString(backupPath, original); - yield* fs.writeFileString(packageJsonPath, `${packageJsonString}\n`); - yield* Effect.log("[cli] Prepared package.json for publish"); - - const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir); - return { iconBackups }; + return { + packageJsonString: yield* encodePackageJson(pkg), + originalPackageJson: yield* fs.readFile(packageJsonPath), + icons: yield* preparePublishIcons(repoRoot, serverDir, version), + }; }), // Use: pnpm publish from the workspace root so pnpm-only workspace // config, including override selectors, is interpreted correctly. - () => + (resource) => Effect.gen(function* () { + yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); + for (const icon of resource.icons) { + yield* fs.writeFile(icon.targetPath, icon.publish); + } + yield* Effect.log("[cli] Applied package metadata and publish icon overrides"); + const args = createVpPmPublishArgs(config); const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); @@ -299,16 +286,14 @@ const publishCmd = Command.make( }), ); }), - // Release: restore - (resource: { readonly iconBackups: ReadonlyArray }) => + // Release: restore every file even if applying overrides or publishing fails. + (resource) => Effect.gen(function* () { - yield* restorePublishIconOverrides(resource.iconBackups).pipe( - Effect.catch((error) => - Effect.logError(`[cli] Failed to restore publish icon overrides: ${String(error)}`), - ), - ); - yield* fs.rename(backupPath, packageJsonPath); - if (config.verbose) yield* Effect.log("[cli] Restored original package.json"); + yield* fs.writeFile(packageJsonPath, resource.originalPackageJson); + for (const icon of resource.icons) { + yield* fs.writeFile(icon.targetPath, icon.original); + } + if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); }), ); }), diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 06a22754e55..42fd3f900e5 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; @@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { const testLayer = Layer.mergeAll( configLayer, WorkspacePaths.layer, - ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), + ), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..440efcee51e 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -10,6 +10,10 @@ import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +import * as SessionStore from "./SessionStore.ts"; + +/** Pinned so dev-mode cookie tests can assert the port-scoped name. */ +const TEST_SERVER_PORT = 13_773; const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( @@ -19,6 +23,9 @@ const makeServerConfigLayer = (overrides?: Partial[0] => ({ cookies: { - t3_session: sessionToken, + [cookieName]: sessionToken, }, headers: {}, }) as unknown as Parameters< @@ -78,6 +86,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("issues standard pairing credentials by default", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; const pairingCredential = yield* serverAuth.issuePairingCredential(); const exchanged = yield* serverAuth.createBrowserSession( @@ -85,7 +94,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { requestMetadata, ); const verified = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(exchanged.sessionToken), + makeCookieRequest(sessions.cookieName, exchanged.sessionToken), ); expect(verified.sessionId.length).toBeGreaterThan(0); @@ -151,6 +160,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("issues startup pairing URLs that bootstrap administrative sessions", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); @@ -164,7 +174,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata); const verified = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(exchanged.sessionToken), + makeCookieRequest(sessions.cookieName, exchanged.sessionToken), ); expect(verified.scopes).toEqual([ @@ -186,13 +196,14 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; const administrativeExchange = yield* serverAuth.createBrowserSession( "desktop-bootstrap-token", requestMetadata, ); const administrativeSession = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(administrativeExchange.sessionToken), + makeCookieRequest(sessions.cookieName, administrativeExchange.sessionToken), ); const pairingCredential = yield* serverAuth.issuePairingCredential({ label: "Julius iPhone", @@ -209,7 +220,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }, ); const clientSession = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(clientExchange.sessionToken), + makeCookieRequest(sessions.cookieName, clientExchange.sessionToken), ); const clientsBeforeRevoke = yield* serverAuth.listClientSessions( administrativeSession.sessionId, diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..eb056342140 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service< readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; + readonly purpose?: "startup"; }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, @@ -746,11 +747,13 @@ export const make = Effect.gen(function* () { readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; + readonly purpose?: "startup"; }) => createPairingLink({ scopes: input.scopes, subject: input.subject, ...(input.label ? { label: input.label } : {}), + ...(input.purpose ? { purpose: input.purpose } : {}), }).pipe( Effect.map( (issued) => @@ -774,6 +777,7 @@ export const make = Effect.gen(function* () { ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), + ...(input?.purpose ? { purpose: input.purpose } : {}), }); return { id: issued.id, @@ -872,6 +876,7 @@ export const make = Effect.gen(function* () { issuePairingCredentialForSubject({ scopes: AuthAdministrativeScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + purpose: "startup", }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 95269fb6c37..8e4c2171088 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -34,6 +34,9 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + // Packaged desktop has no devUrl, but still needs the port scope: it + // scans upward from 3773 for a free port and binds 127.0.0.1, so a second + // instance shares this one's hostname on a different port. expect(descriptor.sessionCookieName).toBe("t3_session_3773"); }).pipe( Effect.provide( @@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ); + it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_3774"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "desktop", + port: 3774, + }), + ), + ), + ); + it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; @@ -69,12 +88,13 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ mode: "web", host: "127.0.0.1", + port: 3773, }), ), ), @@ -87,6 +107,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("remote-reachable"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.sessionCookieName).toBe("t3_session"); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ @@ -97,12 +118,32 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ); + it.effect("isolates wildcard-bound web development sessions", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "web", + host: "0.0.0.0", + port: 5775, + devUrl: new URL("http://127.0.0.1:5736"), + }), + ), + ), + ); + it.effect("uses remote-reachable policy for non-loopback web hosts", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.sessionCookieName).toBe("t3_session"); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 7ffef0ff0a5..9945c69067d 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,8 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; -import { resolveSessionCookieName } from "./utils.ts"; -import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; +import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< EnvironmentAuthPolicy, @@ -16,7 +15,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); + const isRemoteReachable = isRemoteReachableHost(config.host); const policy = config.mode === "desktop" @@ -41,6 +40,9 @@ export const make = Effect.gen(function* () { sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, + host: config.host, + instanceKey: config.stateDir, + development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..057a257ba66 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service< readonly subject?: string; readonly label?: string; readonly proofKeyThumbprint?: string; + /** + * "startup" marks the credential the server mints for itself at boot, + * which gets the long dev TTL when a dev URL is configured. + */ + readonly purpose?: "startup"; }) => Effect.Effect; readonly listActive: () => Effect.Effect< ReadonlyArray, @@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // window can still recover by re-bootstrapping rather than locking // the user out of the backend. const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); +// A dev server's startup token is read off a log by whoever (or whatever) is +// driving the session, often minutes later — after a `node --watch` restart, a +// detour into another task, or a hand-off to the person actually doing the +// testing. Five minutes turns that into a restart-the-server loop for no +// security benefit: the token only unlocks a local dev backend, and its holder +// could read the log anyway. Same reasoning (and duration) as the desktop +// bootstrap grant above. Only applies when a dev URL is configured; user-issued +// pairing links and real servers keep the 5-minute default. +const DEV_STARTUP_TTL_HOURS = Duration.hours(24); const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = @@ -371,7 +385,10 @@ export const make = Effect.gen(function* () { ), ); const credential = yield* generatePairingToken; - const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup"; + const ttl = + input?.ttl ?? + (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES); const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); const issued: IssuedBootstrapCredential = { diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 12ecb7dba4d..40a1c43e0be 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -470,6 +470,9 @@ export const make = Effect.gen(function* () { const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, port: serverConfig.port, + host: serverConfig.host, + instanceKey: serverConfig.stateDir, + development: serverConfig.devUrl !== undefined, }); const emitUpsert = (clientSession: AuthClientSession) => diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 90dc0f8ddf9..edc58f71131 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { deriveAuthClientMetadata } from "./utils.ts"; +import { + deriveAuthClientMetadata, + isRemoteReachableHost, + resolveSessionCookieName, +} from "./utils.ts"; describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { @@ -52,3 +56,80 @@ describe("deriveAuthClientMetadata", () => { expect(metadata.userAgent).toContain("Electron/36.3.2"); }); }); + +describe("session cookie isolation", () => { + it("isolates loopback web servers by port and server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "127.0.0.1", + instanceKey: "/tmp/t3-agent-one", + development: true, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "127.0.0.1", + instanceKey: "/tmp/t3-agent-two", + development: true, + }); + + expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps the hosted web cookie stable across server instances", () => { + expect( + resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/release-a", + development: false, + }), + ).toBe("t3_session"); + expect( + resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/release-b", + development: false, + }), + ).toBe("t3_session"); + }); + + it("retains desktop port scoping", () => { + expect( + resolveSessionCookieName({ + mode: "desktop", + port: 3773, + host: "127.0.0.1", + instanceKey: "/tmp/desktop", + development: true, + }), + ).toBe("t3_session_3773"); + }); + + it("isolates development servers even when they bind a wildcard host", () => { + expect( + resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "0.0.0.0", + instanceKey: "/tmp/t3-wildcard-dev", + development: true, + }), + ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + }); + + it("classifies loopback aliases separately from remotely reachable hosts", () => { + expect(isRemoteReachableHost(undefined)).toBe(false); + expect(isRemoteReachableHost("localhost")).toBe(false); + expect(isRemoteReachableHost("127.12.0.1")).toBe(false); + expect(isRemoteReachableHost("[::1]")).toBe(false); + expect(isRemoteReachableHost("0.0.0.0")).toBe(true); + expect(isRemoteReachableHost("192.168.1.50")).toBe(true); + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..32a6799b01f 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -10,15 +10,60 @@ import * as Result from "effect/Result"; const SESSION_COOKIE_NAME = "t3_session"; +/** + * Cookies are scoped by host but *not* by port, so any two servers that can be + * live on one hostname at once need separate names — otherwise the second + * clobbers the first's session and both sides see "Invalid session token + * signature" until someone clears cookies by hand. + * + * Two populations qualify, for the same reason but from different causes: + * + * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. + * - **Desktop**, which scans upward from 3773 for a free port and binds + * 127.0.0.1, so a second instance lands on a different port and the same host. + * + * Hosted deployments keep the stable production name: their public port can + * change between releases, and scoping it would log every user out. + */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; + readonly host: string | undefined; + readonly instanceKey: string; + readonly development: boolean; }): string { - if (input.mode !== "desktop") { + if (input.mode === "desktop") { + return `${SESSION_COOKIE_NAME}_${input.port}`; + } + + if (!input.development && isRemoteReachableHost(input.host)) { return SESSION_COOKIE_NAME; } - return `${SESSION_COOKIE_NAME}_${input.port}`; + // Cookies are scoped by host, not port. Loopback development servers need an + // instance-specific name or parallel agents overwrite each other's session, + // and a server that later reuses the port receives a token signed elsewhere. + const instanceHash = NodeCrypto.createHash("sha256") + .update(input.instanceKey) + .digest("hex") + .slice(0, 12); + return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; +} + +export function isRemoteReachableHost(host: string | undefined): boolean { + if (host === "0.0.0.0" || host === "::" || host === "[::]") { + return true; + } + if (!host || host.length === 0) { + return false; + } + return !( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); } export function base64UrlEncode(input: string | Uint8Array): string { diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 47d95c865e0..a07e4c19f0e 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -84,6 +84,7 @@ const makeCliTestServerConfig = (baseDir: string) => ...derivedPaths, staticDir: undefined, devUrl: undefined, + devAllowedOrigins: [], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, @@ -205,6 +206,18 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), ); + it.effect("exposes service lifecycle commands without T3 Connect configuration", () => + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["service", "--help"], noConnectCli)); + + assert.include(output, "Manage the T3 Code background service."); + assert.include(output, "install"); + assert.include(output, "uninstall"); + assert.include(output, "update"); + assert.include(output, "status"); + }), + ); + it.effect("reports fresh headless connect state without requiring local configuration", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( @@ -308,7 +321,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { runConnectCli(["connect", "logout", "--base-dir", baseDir]), ); - assert.equal(output, "Signed out of T3 Connect locally."); + assert.equal( + output, + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); assert.isFalse(NodeFS.existsSync(tokenPath)); }), ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index a9bfcaa4bf7..9e3ef41aa05 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -14,6 +14,7 @@ import { sharedServerCommandFlags } from "./cli/config.ts"; import { importCommand } from "./cli/import.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; +import { serviceCommand } from "./cli/service.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -49,6 +50,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, importCommand, + serviceCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f6c2a63e192..39c00053fef 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + devAllowedOrigins: [], } as const; const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { @@ -95,6 +96,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOST: "0.0.0.0", T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + T3CODE_DEV_ALLOWED_ORIGINS: + "https://host.example.ts.net, https://phone.example.ts.net ", T3CODE_NO_BROWSER: "true", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", @@ -117,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { host: "0.0.0.0", staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), + devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 15ef3a229c3..4efd4c093b1 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -106,6 +106,15 @@ const EnvServerConfig = Config.all({ host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + devAllowedOrigins: Config.string("T3CODE_DEV_ALLOWED_ORIGINS").pipe( + Config.withDefault(""), + Config.map((value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + ), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -378,6 +387,7 @@ export const resolveServerConfig = ( host, staticDir, devUrl, + devAllowedOrigins: env.devAllowedOrigins, noBrowser, startupPresentation, desktopBootstrapToken, diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index e63cbf331b5..cc52a50fe36 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -16,9 +16,9 @@ import { formatRelayClientReady, headlessSessionConfig, isPublishAgentActivityEnabledValue, - recoverBootServiceOffer, reportCloudDisconnectResults, } from "./connect.ts"; +import { recoverServiceOnboardingOffer } from "./service.ts"; it("explains how to complete headless authorization", () => { assert.equal( @@ -58,7 +58,7 @@ it.effect("detects headless operation from individual SSH config values", () => it.effect("treats cancelling optional background setup as a successful skip", () => Effect.gen(function* () { - const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({}))); + const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); assert.isFalse(result); }), ); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 8b11fc73346..0369d47e4d7 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,7 +6,6 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; -import * as Terminal from "effect/Terminal"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; @@ -20,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; +import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -29,7 +29,6 @@ import { } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; -import packageJson from "../../package.json" with { type: "json" }; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BootService from "../cloud/bootService.ts"; @@ -45,9 +44,13 @@ import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; -import * as ProcessRunner from "../processRunner.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { + bootServiceLayer, + offerServiceDuringOnboarding, + recoverServiceOnboardingOffer, +} from "./service.ts"; const jsonFlag = Flag.boolean("json").pipe( Flag.withDescription("Emit JSON instead of human-readable output."), @@ -396,21 +399,6 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { if (options.clearAuthorization) { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; - - // uninstall itself no-ops when nothing is installed (and on non-Linux), - // so no status pre-check that could mask a real removal failure. - const bootService = yield* BootService.BootService; - yield* bootService.uninstall.pipe( - Effect.tap((removed) => - removed ? Console.log("Removed the T3 Code background service.") : Effect.void, - ), - Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), - Effect.catch((error) => - Console.warn(`Could not remove the background service: ${error.message}`).pipe( - Effect.as(false), - ), - ), - ); } yield* reportCloudDisconnectResults({ @@ -420,7 +408,9 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { }); if (options.clearAuthorization) { - yield* Console.log("Signed out of T3 Connect locally."); + yield* Console.log( + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); } }); @@ -457,11 +447,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ( - offer: Effect.Effect, -) => - offer.pipe( - Effect.catchTags({ - QuitError: () => Effect.succeed(false), - BootServiceUnsupportedError: (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), - BootServiceCommandError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServiceInstallError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - }), - ); - export const connectCommand = Command.make("connect", { ...projectLocationFlags, headless: headlessFlag, @@ -748,7 +690,7 @@ export const connectCommand = Command.make("connect", { // Connect itself already succeeded; a boot-service failure must not // fail the command, just tell the user what happened and move on. - const background = yield* recoverBootServiceOffer(offerBootService); + const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); yield* Console.log( background ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out." diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts new file mode 100644 index 00000000000..e91e10000b6 --- /dev/null +++ b/apps/server/src/cli/service.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; + +import { formatServiceStatus } from "./service.ts"; + +const status = { + supported: true, + installed: true, + current: true, + unitPath: "/home/me/.config/systemd/user/t3code.service", + logPath: "/home/me/.t3/userdata/logs/boot-service.log", +} as const; + +it("reports the installed service version and host paths", () => { + assert.equal( + formatServiceStatus(status, "0.0.29"), + [ + "T3 Code service", + " Status: installed · t3@0.0.29", + " Unit: /home/me/.config/systemd/user/t3code.service", + " Logs: /home/me/.t3/userdata/logs/boot-service.log", + ].join("\n"), + ); +}); + +it("gives a direct repair command for a stale service", () => { + assert.include( + formatServiceStatus({ ...status, current: false }, "0.0.29"), + "Next: Run `npx t3@latest service update`.", + ); +}); + +it("explains service availability without systemd", () => { + assert.include( + formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), + "Supported on: Linux with systemd", + ); +}); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts new file mode 100644 index 00000000000..bd846eeee34 --- /dev/null +++ b/apps/server/src/cli/service.ts @@ -0,0 +1,199 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Terminal from "effect/Terminal"; +import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import type * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) => + BootService.layer({ + baseDir: config.baseDir, + logsDir: config.logsDir, + cliVersion: packageJson.version, + }).pipe(Layer.provide(ProcessRunner.layer)); + +export type ServiceReconcileResult = + | { + readonly changed: false; + readonly status: BootService.BootServiceStatus; + } + | { + readonly changed: true; + readonly previouslyInstalled: boolean; + readonly plan: BootService.BootServicePlan; + }; + +/** Install, update, or repair the service using the CLI version running this command. */ +export const reconcileService = Effect.fn("cli.service.reconcile")(function* () { + const service = yield* BootService.BootService; + const status = yield* service.status; + if (status.installed && status.current) { + return { changed: false, status } satisfies ServiceReconcileResult; + } + const plan = yield* service.install; + return { + changed: true, + previouslyInstalled: status.installed, + plan, + } satisfies ServiceReconcileResult; +}); + +export function formatServiceStatus( + status: BootService.BootServiceStatus, + cliVersion: string, +): string { + if (!status.supported) { + return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd"; + } + if (!status.installed) { + return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; + } + return [ + "T3 Code service", + ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`, + ` Unit: ${status.unitPath}`, + ` Logs: ${status.logPath}`, + ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), + ].join("\n"); +} + +const runServiceCommand = Effect.fn("cli.service.run")(function* ( + flags: { readonly baseDir: Parameters[0]["baseDir"] }, + run: Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* run.pipe(Effect.provide(bootServiceLayer(config))); +}); + +const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe( + Command.withDescription("Install T3 Code as a background service for this user."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log( + `T3 Code service is already installed with t3@${packageJson.version}.`, + ); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( + Command.withDescription( + "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", + ), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( + Command.withDescription("Stop and remove the T3 Code background service."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + const removed = yield* service.uninstall; + yield* Console.log( + removed ? "Removed the T3 Code service." : "T3 Code service is not installed.", + ); + }), + ), + ), +); + +const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( + Command.withDescription("Show whether the T3 Code background service is installed."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + }), + ), + ), +); + +export const offerServiceDuringOnboarding = Effect.gen(function* () { + const service = yield* BootService.BootService; + const { supported, installed, current } = yield* service.status; + if (!supported) { + return false; + } + if (installed && current) { + yield* Console.log("T3 Code is already set up to run in the background on this machine."); + return true; + } + const wanted = yield* Prompt.run( + Prompt.confirm({ + message: installed + ? "The installed T3 Code service needs an update or repair. Update it now?" + : "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", + initial: true, + }), + ); + if (!wanted) { + return false; + } + const result = yield* reconcileService(); + if (result.changed) { + yield* Console.log( + `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, + ); + } + return true; +}); + +export const recoverServiceOnboardingOffer = ( + offer: Effect.Effect, +) => + offer.pipe( + Effect.catchTags({ + QuitError: () => Effect.succeed(false), + BootServiceUnsupportedError: (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + BootServiceCommandError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceInstallError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + }), + ); + +export const serviceCommand = Command.make("service").pipe( + Command.withDescription("Manage the T3 Code background service."), + Command.withSubcommands([ + serviceInstallCommand, + serviceUninstallCommand, + serviceUpdateCommand, + serviceStatusCommand, + ]), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a24d54697d9..46e9a1da987 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -14,6 +14,7 @@ import { HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { reconcileService } from "../cli/service.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; @@ -100,7 +101,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { unit, [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", "StartLimitIntervalSec=300", "StartLimitBurst=5", "", @@ -108,6 +109,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { "Type=simple", "WorkingDirectory=%h", "Environment=T3CODE_HOME=/home/theo/.t3", + "Environment=T3_BOOT_SERVICE_UNIT=t3code.service", "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", "Restart=always", "RestartSec=5", @@ -170,6 +172,33 @@ it("flags package-manager cache entry points as ephemeral", () => { }); it.layer(NodeServices.layer)("BootService", (it) => { + it.effect("reconciles the standalone service once and is then idempotent", () => + Effect.gen(function* () { + const { dirs } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const first = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isTrue(first.changed); + if (!first.changed) return; + assert.isFalse(first.previouslyInstalled); + + const commandCount = commands.length; + const second = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isFalse(second.changed); + assert.lengthOf(commands, commandCount); + }), + ); + it.effect("installs the unit, enables the service, and enables linger", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); @@ -311,7 +340,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { }), ); - it.effect("reports an installed-but-stale unit so connect can offer a repair", () => + it.effect("reports an installed-but-stale unit so the lifecycle can offer a repair", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); const commands: Array = []; @@ -402,8 +431,8 @@ it.layer(NodeServices.layer)("BootService", (it) => { const error = yield* service.install.pipe(Effect.flip); assert.isTrue(isCommandError(error)); - // A leftover unit would make the next connect report "already set up" - // even though linger never happened. + // A leftover unit would make status report "installed" even though + // linger never happened. assert.isFalse( yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0662b367049..d7e13e834f4 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,21 +16,19 @@ import { } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; +import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; /** - * Installs T3 Code as a per-user boot service so a connected machine stays - * reachable through T3 Connect after the SSH session ends. Linux-only for - * now: systemd user unit + loginctl enable-linger. The service runs a pinned - * runtime installed under /runtime — never `npx t3`, whose cache is - * ephemeral and whose registry fetch at boot would make startup depend on - * the network. + * Installs T3 Code as a per-user boot service. Linux-only for now: systemd + * user unit + loginctl enable-linger. The service runs a stable or pinned + * runtime — never an ephemeral `npx t3` cache whose eviction could break + * startup. */ const BOOT_SERVICE_NAME = "t3code"; -const BOOT_RUNTIME_DIR = "runtime"; -const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; -const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; const EPHEMERAL_CACHE_SEGMENTS = [ "/_npx/", // npx @@ -92,7 +90,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { // relay connection, and Restart=always covers early-boot failures. return [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", // Give up after 5 crashes in 5 minutes so a persistently broken install // (deleted runtime, broken workspace) stops instead of restarting every // 5s forever and growing the unrotated append log without bound. @@ -103,6 +101,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, "Restart=always", "RestartSec=5", @@ -206,14 +205,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); - const runtimeVersionDir = path.join( - input.baseDir, - BOOT_RUNTIME_DIR, - "versions", - input.cliVersion, - ); - const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); - const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); const requireSystemdLinux = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { @@ -263,52 +255,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!isEphemeralCacheEntry(host.cliEntryPath)) { return; } - // The sentinel is written only after npm exits 0. Checking the entry - // file alone is not enough: npm extracts files before running native - // builds (node-pty), so a killed install leaves a plausible-looking but - // broken tree behind. - const alreadyPinned = yield* Effect.all([ - fs.exists(runtimeSentinelPath), - fs.exists(runtimeEntryPath), - ]).pipe( - Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - if (alreadyPinned) { - return; - } - yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( - Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - yield* runStep( - "installing the pinned t3 runtime (this can take a few minutes)", - "npm", - [ - "install", - "--prefix", - runtimeVersionDir, - "--no-fund", - "--no-audit", - `t3@${input.cliVersion}`, - ], - // Native deps (node-pty) can compile from source on slow boxes; the - // ProcessRunner default of 60s would kill a healthy install. - { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, - ).pipe( - Effect.tapError(() => - fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + yield* ensurePinnedRuntimeInstalled({ + baseDir: input.baseDir, + version: input.cliVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => + error.step.startsWith("installing") + ? new BootServiceCommandError({ + step: error.step, + exitCode: error.exitCode, + stdoutLength: error.stdoutLength, + stderrLength: error.stderrLength, + cause: error.cause, + }) + : new BootServiceInstallError({ cause: error }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), ), ); - yield* fs - .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); }); // Where the unit will point: derivable without touching the network, so // status can compare units purely; install materializes it first. const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) - ? runtimeEntryPath + ? runtimePaths.entryPath : host.cliEntryPath; const plan: BootServicePlan = { nodePath: host.execPath, @@ -341,8 +322,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); // If any activation step fails, remove the unit again: a leftover file - // would make the next `t3 connect` report the service as already set up - // even though it was never enabled or lingered. + // would make service status report it as installed even though it was + // never enabled or lingered. yield* Effect.gen(function* () { yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); yield* runStep("enabling the service", "systemctl", [ @@ -372,7 +353,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // fails), leave nothing behind: disable removes the enable symlink, remove // deletes the file, daemon-reload clears the stale definition — otherwise a // dangling wants/ symlink logs "Failed to load unit" at every boot and the - // next connect misreports the state. + // next lifecycle command misreports the state. const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( previousUnit: Option.Option, ) { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts new file mode 100644 index 00000000000..9b46c0038ec --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { + ensurePinnedRuntimeInstalled, + pinnedRuntimePaths, + removePinnedRuntimeInstallation, +} from "./pinnedRuntime.ts"; + +it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("serializes concurrent installs of the same runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.29"); + let npmRuns = 0; + + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + npmRuns += 1; + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + const install = ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.29", + fs, + path, + runner, + }); + + const first = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Deferred.await(installStarted); + const second = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Effect.yieldNow; + assert.equal(npmRuns, 1); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + assert.equal(npmRuns, 1); + assert.isTrue(yield* fs.exists(paths.sentinelPath)); + assert.isTrue(yield* fs.exists(paths.entryPath)); + }), + ); + + it.effect("waits for an active install before removing its runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.30"); + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + + const installFiber = yield* Effect.forkChild( + ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.30", + fs, + path, + runner, + }), + { startImmediately: true }, + ); + yield* Deferred.await(installStarted); + const removeFiber = yield* Effect.forkChild( + removePinnedRuntimeInstallation({ + baseDir, + version: "0.0.30", + fs, + path, + }), + { startImmediately: true }, + ); + yield* Effect.yieldNow; + assert.isTrue(yield* fs.exists(paths.versionDir)); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(installFiber); + yield* Fiber.join(removeFiber); + assert.isFalse(yield* fs.exists(paths.versionDir)); + }), + ); +}); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts new file mode 100644 index 00000000000..e3e095ce081 --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -0,0 +1,176 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * A pinned runtime is an exact `t3@` npm-installed into + * /runtime/versions/. The boot service points its systemd + * unit here, and server self-update installs the target version here before + * switching over — never `npx t3`, whose cache is ephemeral and whose + * registry fetch at boot would make startup depend on the network. + */ + +const PINNED_RUNTIME_DIR = "runtime"; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +// Boot-service setup and remote self-update share this module but can be +// constructed in separate layers. Serialize the complete check/install/ +// sentinel transaction across all callers in this process. +const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); + +export interface PinnedRuntimePaths { + readonly versionDir: string; + readonly entryPath: string; + readonly sentinelPath: string; +} + +export function pinnedRuntimePaths( + path: Path.Path, + baseDir: string, + version: string, +): PinnedRuntimePaths { + const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + return { + versionDir, + entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: path.join(versionDir, ".install-complete"), + }; +} + +export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass()( + "PinnedRuntimeInstallError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Pinned runtime install failed while ${this.step}.` + : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`; + } +} + +/** + * Installs `t3@` into the pinned runtime directory unless a complete + * install is already there, and returns its paths. The sentinel is written + * only after npm exits 0; checking the entry file alone is not enough — npm + * extracts files before running native builds (node-pty), so a killed + * install leaves a plausible-looking but broken tree behind. + */ +export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly runner: ProcessRunner.ProcessRunner["Service"]; + }) { + const { fs, runner } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + + return yield* pinnedRuntimeInstallLock.withPermit( + Effect.gen(function* () { + const alreadyPinned = yield* Effect.all([ + fs.exists(paths.sentinelPath), + fs.exists(paths.entryPath), + ]).pipe( + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), + ), + ); + if (alreadyPinned) { + return paths; + } + + yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "preparing the pinned runtime directory", + cause, + }), + ), + ); + + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + yield* runner + .run({ + command: "npm", + args: [ + "install", + "--prefix", + paths.versionDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError(() => + fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + + yield* fs + .writeFileString(paths.sentinelPath, `${input.version}\n`) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), + ), + ); + + return paths; + }), + ); + }, +); + +/** Removes one pinned runtime while holding the same process-wide lock used + * by install/check/sentinel work, so cleanup cannot race another caller that + * is materializing or reusing the runtime tree. */ +export const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + }) { + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + yield* pinnedRuntimeInstallLock.withPermit( + input.fs + .remove(paths.versionDir, { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "removing the pinned runtime", cause }), + ), + ), + ); + }, +); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts new file mode 100644 index 00000000000..9d6e3801704 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -0,0 +1,589 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + renderBootServiceUnit, +} from "./bootService.ts"; +import * as SelfUpdate from "./selfUpdate.ts"; + +const NODE_PATH = "/usr/local/bin/node"; + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { + readonly failWhen?: ((command: string, args: ReadonlyArray) => boolean) | undefined; + readonly stdoutFor?: + | ((command: string, args: ReadonlyArray) => string | undefined) + | undefined; + }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + commands.push({ command: input.command, args: input.args }); + const failed = options?.failWhen?.(input.command, input.args) === true; + const versionFromPath = + input.command === NODE_PATH && input.args[1] === "--version" + ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1] + : undefined; + return { + stdout: + options?.stdoutFor?.(input.command, input.args) ?? + (versionFromPath === undefined ? "" : `t3 v${versionFromPath}\n`), + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const provideHostRefs = (input: { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly entryPath: string; +}) => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed(HostProcessEnvironment, input.env), + Layer.succeed(HostProcessExecutablePath, NODE_PATH), + Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), + ), + ); + +it("recognizes published npm artifacts as swappable entry points", () => { + assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isTrue( + SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + SelfUpdate.isPublishedCliEntry( + "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + ), + ); + // Dev checkouts and the desktop bundle run apps/server/dist directly. + assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); + assert.isFalse(SelfUpdate.isPublishedCliEntry("")); +}); + +it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { + const makeHome = Effect.fn("test.makeHome")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + return { fs, path, home }; + }); + + const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( + home: string, + entryPath: string, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir: path.join(home, ".t3"), + logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + }); + + it.effect("reports boot-service for the systemd-spawned unit process", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "boot-service"); + }), + ); + + it.effect("does not claim a systemd process owned by another unit", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { HOME: home, INVOCATION_ID: "abc123" }, + entryPath, + }), + ); + assert.isNull(method); + }), + ); + + it.effect("reports respawn for a manual run of the pinned artifact", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + // Same unit on disk, but no INVOCATION_ID: restarting the unit would + // not replace this process, so it must respawn itself instead. + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports respawn for a foreground npx artifact on darwin", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, + }), + ); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports desktop-managed for desktop-supervised backends", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + // Desktop ownership wins over every process-shape heuristic: even a + // systemd-looking pinned artifact belongs to the app that spawned it. + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: true, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "desktop-managed"); + }), + ); + + it.effect("reports no method for dev checkouts and Windows", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, + }), + ); + assert.isNull(devMethod); + const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "win32", + env: { HOME: home }, + entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + }), + ); + assert.isNull(windowsMethod); + }), + ); +}); + +it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { + interface RecordedSpawn { + readonly command: string; + readonly args: ReadonlyArray; + } + + const makeContext = Effect.fn("test.makeContext")(function* (options?: { + readonly platform?: NodeJS.Platform; + readonly bootService?: boolean; + readonly desktopManaged?: boolean; + readonly entryPath?: string; + readonly failWhen?: (command: string, args: ReadonlyArray) => boolean; + readonly stdoutFor?: (command: string, args: ReadonlyArray) => string | undefined; + readonly failSpawn?: boolean; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + const baseDir = path.join(home, ".t3"); + const entryPath = + options?.entryPath ?? + path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + const env: NodeJS.ProcessEnv = + options?.bootService === true + ? { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + } + : { HOME: home }; + if (options?.bootService === true) { + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir, + logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + } + + const commands: Array = []; + const spawns: Array = []; + let exited = 0; + // layerTest always reports mode "web"; desktop-managed contexts overlay + // the mode the desktop app's bootstrap envelope would set. + const configLayer = + options?.desktopManaged === true + ? Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { ...config, mode: "desktop" as const }; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) + : ServerConfig.layerTest(home, baseDir); + const service = yield* SelfUpdate.make({ + host: { + spawnDetached: (command, args) => + Effect.sync(() => spawns.push({ command, args })).pipe( + Effect.andThen( + options?.failSpawn === true + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause: new Error("detached spawn failed"), + }), + ) + : Effect.void, + ), + ), + exitProcess: () => { + exited += 1; + }, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeRecordingRunnerLayer(commands, { + failWhen: options?.failWhen, + stdoutFor: options?.stdoutFor, + }), + configLayer, + ), + ), + provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), + ); + return { + fs, + path, + home, + baseDir, + entryPath, + commands, + spawns, + exitCount: () => exited, + service, + }; + }); + + it.effect("rejects dist-tags and other non-exact versions", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); + assert.include(error.reason, "not an exact t3 version"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("refuses to update a desktop-managed backend and points at the app", () => + Effect.gen(function* () { + const context = yield* makeContext({ desktopManaged: true, bootService: true }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "desktop app"); + assert.lengthOf(context.commands, 0); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("fails without touching anything when no update method applies", () => + Effect.gen(function* () { + const context = yield* makeContext({ + entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", + }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "cannot update itself"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("surfaces a failed npm install and never schedules a restart", () => + Effect.gen(function* () { + const context = yield* makeContext({ failWhen: (command) => command === "npm" }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.equal(error.reason, "Could not install the requested t3 version."); + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("reinstalls the same version after a failed preflight", () => + Effect.gen(function* () { + let preflightAttempts = 0; + const context = yield* makeContext({ + failWhen: (command) => { + if (command !== NODE_PATH) return false; + preflightAttempts += 1; + return preflightAttempts === 1; + }, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true }); + yield* context.fs.writeFileString(entryPath, "export {};\n"); + yield* context.fs.writeFileString( + context.path.join(versionDir, ".install-complete"), + "0.0.29\n", + ); + + const firstError = yield* context.service + .update({ targetVersion: "0.0.29" }) + .pipe(Effect.flip); + assert.include(firstError.reason, "failed its version check"); + assert.isFalse(yield* context.fs.exists(versionDir)); + + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.deepEqual( + context.commands.map((entry) => entry.command), + [NODE_PATH, "npm", NODE_PATH], + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rejects and removes an installed runtime that reports the wrong version", () => + Effect.gen(function* () { + const context = yield* makeContext({ + stdoutFor: (command, args) => + command === NODE_PATH && args[1] === "--version" ? "t3 v0.0.28\n" : undefined, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + + assert.include(error.reason, "did not report the requested"); + assert.isFalse(yield* context.fs.exists(versionDir)); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("reports a detached replacement spawn failure and leaves updates retryable", () => + Effect.gen(function* () { + const context = yield* makeContext({ failSpawn: true }); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Could not start the replacement"); + + const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(second.reason, "Could not start the replacement"); + assert.notInclude(second.reason, "already in progress"); + assert.lengthOf(context.spawns, 2); + assert.equal(context.exitCount(), 0); + }), + ); + + it.effect("installs, preflights, and respawns a foreground server", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.lengthOf(context.spawns, 1); + + const concurrentError = yield* context.service + .update({ targetVersion: "0.0.30" }) + .pipe(Effect.flip); + assert.include(concurrentError.reason, "already in progress"); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + assert.deepEqual( + context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, + `${NODE_PATH} ${pinnedEntry} --version`, + ], + ); + + // The restart is deferred so the RPC acknowledgement flushes first. + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 1); + const spawn = context.spawns[0]; + assert.equal(spawn?.command, "/bin/sh"); + assert.include(spawn?.args ?? [], pinnedEntry); + // The replacement replays the original CLI arguments. + assert.include(spawn?.args ?? [], "serve"); + assert.equal(context.exitCount(), 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rewrites the systemd unit and restarts the boot service", () => + Effect.gen(function* () { + const context = yield* makeContext({ bootService: true }); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + const unit = yield* context.fs.readFileString( + context.path.join(context.home, ".config", "systemd", "user", "t3code.service"), + ); + assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); + + assert.deepEqual(context.commands[3], { + command: "systemctl", + args: ["--user", "restart", "t3code.service"], + }); + assert.lengthOf(context.spawns, 0); + // systemd replaces the process; the server must not exit itself. + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous unit and permits a retry when systemd restart fails", () => + Effect.gen(function* () { + let failRestart = true; + const context = yield* makeContext({ + bootService: true, + failWhen: (command, args) => { + if (command !== "systemctl" || args[1] !== "restart" || !failRestart) { + return false; + } + failRestart = false; + return true; + }, + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Restarting the systemd boot service failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.slice(-2).map((entry) => entry.args), + [ + ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + ["--user", "daemon-reload"], + ], + ); + + const retry = yield* context.service.update({ targetVersion: "0.0.30" }); + assert.deepEqual(retry, { targetVersion: "0.0.30", method: "boot-service" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous systemd unit when daemon-reload fails", () => + Effect.gen(function* () { + const context = yield* makeContext({ + bootService: true, + failWhen: (command) => command === "systemctl", + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "Reloading systemd units failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts new file mode 100644 index 00000000000..62bd07fbbc8 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.ts @@ -0,0 +1,430 @@ +// @effect-diagnostics nodeBuiltinImport:off +// node:child_process directly: the foreground-server replacement must be a +// detached fire-and-forget child that outlives this process, while Effect's +// ChildProcessSpawner ties every child to a scope that kills it. +import { + ServerSelfUpdateError, + type ServerSelfUpdateCapability, + type ServerSelfUpdateInput, + type ServerSelfUpdateResult, +} from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ServerConfig from "../config.ts"; +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + quoteSystemdValue, + renderBootServiceUnit, +} from "./bootService.ts"; +import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts"; + +/** + * Lets a connected client replace this server with another published `t3` + * version over RPC — the only update path that works when the user is not at + * the machine (phone against a home server, relay-managed box). The target + * version is npm-installed into the pinned runtime and verified before + * anything restarts, so a failed install leaves the running server untouched. + */ + +const PREFLIGHT_TIMEOUT = Duration.seconds(30); +/** Grace between acknowledging the RPC and killing the process, so the + response (and its relay hop) flushes before the socket drops. */ +const RESTART_DELAY = Duration.seconds(2); + +/** Exact npm versions only — never dist-tags — so the acknowledgement names + the version that was actually installed. Also keeps the value safe to + pass to npm and embed in filesystem paths. */ +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export interface ServerSelfUpdateHost { + readonly execPath: string; + readonly cliEntryPath: string; + /** Original CLI arguments after the entry path, replayed on respawn. */ + readonly cliArgs: ReadonlyArray; + /** Resolves once the foreground replacement process has actually spawned. */ + readonly spawnDetached: ( + command: string, + args: ReadonlyArray, + ) => Effect.Effect; + readonly exitProcess: () => void; +} + +function normalizeEntryPath(entryPath: string): string { + return entryPath.replaceAll("\\", "/"); +} + +/** + * Only a published npm artifact can be swapped for another version: dev + * checkouts (apps/server/dist) and the desktop app's bundled backend have no + * npm identity, and the desktop manages its own updates. + */ +export function isPublishedCliEntry(entryPath: string): boolean { + return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/"); +} + +/** + * The update path this process can offer, or null when only a manual + * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this + * backend and owns its version; only updating the app updates it. + * "boot-service" — this is the systemd-supervised process from + * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a + * foreground POSIX process running a published artifact: replace it with a + * detached child. Windows foreground runs are unsupported for now (no + * equivalent of the detach-and-exec handoff below). + */ +export const resolveServerSelfUpdateCapability = Effect.fn( + "cloud.server_self_update.resolve_capability", +)(function* (input: { + /** True when the desktop app supervises this backend (mode "desktop"). */ + readonly desktopManaged: boolean; +}) { + if (input.desktopManaged) { + return "desktop-managed" as const; + } + + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; + const hostArguments = yield* HostProcessArguments; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entryPath = hostArguments[1] ?? ""; + if (entryPath === "") { + return null; + } + + const homeDir = env.HOME ?? ""; + if (platform === "linux" && homeDir !== "") { + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe( + Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))), + Effect.orElseSucceed(() => false), + ); + // INVOCATION_ID only proves that some systemd unit launched us. The + // explicit marker written into t3code.service identifies this unit as the + // supervisor that will replace the current process when restarted. + if ( + unitReferencesEntry && + (env.INVOCATION_ID ?? "") !== "" && + env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE + ) { + return "boot-service" as const; + } + + // A process owned by another (or a legacy unmarked) systemd unit must not + // use the foreground respawn path: Restart=always could otherwise launch + // the old unit beside the detached replacement. + if ((env.INVOCATION_ID ?? "") !== "") { + return null; + } + } + + if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) { + return "respawn" as const; + } + + return null; +}); + +export class ServerSelfUpdate extends Context.Service< + ServerSelfUpdate, + { + readonly update: ( + input: ServerSelfUpdateInput, + ) => Effect.Effect; + } +>()("t3/cloud/selfUpdate/ServerSelfUpdate") {} + +export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: { + readonly host?: Partial; +}) { + const serverConfig = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const env = yield* HostProcessEnvironment; + const hostExecPath = yield* HostProcessExecutablePath; + const hostArguments = yield* HostProcessArguments; + const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); + + const host: ServerSelfUpdateHost = { + execPath: options?.host?.execPath ?? hostExecPath, + cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "", + cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2), + spawnDetached: + options?.host?.spawnDetached ?? + ((command, args) => + Effect.callback((resume) => { + const spawnError = (cause: unknown) => + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause, + }); + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn(command, [...args], { + detached: true, + stdio: "ignore", + }); + } catch (cause) { + resume(Effect.fail(spawnError(cause))); + return; + } + + const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause))); + child.once("error", onSpawnError); + child.once("spawn", () => { + child.removeListener("error", onSpawnError); + // Keep asynchronous child errors from becoming uncaught after the + // successful spawn handoff has already been acknowledged. + child.on("error", () => undefined); + child.unref(); + resume(Effect.void); + }); + })), + exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)), + }; + + const inFlight = yield* Ref.make(false); + + const failWith = (reason: string, cause?: unknown) => + cause === undefined + ? new ServerSelfUpdateError({ reason }) + : new ServerSelfUpdateError({ reason, cause }); + + /** Deferred so the RPC acknowledgement flushes before the process dies. + Detached from the request scope: the triggering connection is exactly + what the restart tears down. */ + const scheduleRestart = (restart: Effect.Effect) => + Effect.sleep(RESTART_DELAY).pipe( + Effect.andThen(restart), + Effect.forkDetach({ startImmediately: true }), + ); + const writeUnitAtomically = (filePath: string, contents: string) => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( + "cloud.server_self_update.update", + )(function* (input) { + if (capability === "desktop-managed") { + return yield* failWith( + "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", + ); + } + if (capability === null) { + return yield* failWith( + "This server cannot update itself; relaunch it manually with the new version.", + ); + } + const activeMethod = capability; + const targetVersion = input.targetVersion.trim(); + if (!EXACT_VERSION_PATTERN.test(targetVersion)) { + return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); + } + + const alreadyRunning = yield* Ref.getAndSet(inFlight, true); + if (alreadyRunning) { + return yield* failWith("A server update is already in progress."); + } + + return yield* Effect.gen(function* () { + const runtimePaths = yield* ensurePinnedRuntimeInstalled({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), + ); + + // A broken artifact (failed native build, incompatible node) must be + // caught while the current server is still alive to report it. + const preflight = yield* runner + .run({ + command: host.execPath, + args: [runtimePaths.entryPath, "--version"], + timeout: PREFLIGHT_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => + failWith(`Could not verify the installed t3@${targetVersion}.`, cause), + ), + ); + // Effect CLI's unstable formatVersion currently emits `${name} v${version}`. + // Extract the version token so surrounding presentation changes do not break updates. + const reportedVersion = /\bv(\S+)\s*$/.exec(preflight.stdout)?.[1]; + if (preflight.code !== 0 || reportedVersion !== targetVersion) { + // A completed npm install can still be unusable under this Node or on + // this machine. Remove its sentinel and tree so a retry of the same + // version performs a clean install instead of reusing a known-bad one. + yield* removePinnedRuntimeInstallation({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + }).pipe( + Effect.mapError((error) => + failWith(`Could not remove the failed t3@${targetVersion} installation.`, error), + ), + ); + return yield* failWith( + preflight.code !== 0 + ? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).` + : `The installed runtime did not report the requested t3@${targetVersion} version.`, + ); + } + + if (activeMethod === "boot-service") { + const homeDir = env.HOME ?? ""; + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const previousUnit = yield* fs + .readFileString(unitPath) + .pipe( + Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)), + ); + // Same shape bootService.install writes, so host lifecycle commands + // still recognize the unit as current. + const unit = renderBootServiceUnit({ + nodePath: host.execPath, + t3EntryPath: runtimePaths.entryPath, + baseDir: serverConfig.baseDir, + logPath: path.join(serverConfig.logsDir, "boot-service.log"), + unitPath, + }); + yield* writeUnitAtomically(unitPath, unit).pipe( + Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)), + ); + + const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () { + const reload = yield* runner + .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) + .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); + if (reload.code !== 0) { + return yield* failWith( + `Reloading systemd units failed (exit code ${String(reload.code)}).`, + ); + } + }); + + yield* reloadSystemd().pipe( + Effect.catch((reloadError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.mapError((rollbackCause) => + failWith("Could not restore the previous systemd unit.", { + reloadError, + rollbackCause, + }), + ), + // Systemd should still have the old unit in memory after the + // failed reload, but retry after restoring in case it applied a + // partial update before returning an error. + Effect.andThen(reloadSystemd().pipe(Effect.ignore)), + Effect.andThen(Effect.fail(reloadError)), + ), + ), + ); + yield* Effect.logInfo("Server self-update installed; restarting boot service.", { + targetVersion, + }); + // A successful systemd restart stops this process, so the RPC is + // interrupted and the reconnecting client observes the new version. + // A rejected restart returns while the old process is still alive; + // restore the previous unit and report that failure through the RPC. + yield* Effect.gen(function* () { + const restart = yield* runner + .run({ + command: "systemctl", + args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + }) + .pipe( + Effect.mapError((cause) => + failWith("Could not restart the systemd boot service.", cause), + ), + ); + if (restart.code !== 0) { + return yield* failWith( + `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`, + ); + } + }).pipe( + Effect.catch((restartError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.andThen(reloadSystemd()), + Effect.mapError((rollbackError) => + failWith("Could not restore the previous systemd unit.", { + restartError, + rollbackError, + }), + ), + Effect.andThen(Effect.fail(restartError)), + ), + ), + ); + } else { + // Spawn the shim before acknowledging the RPC so ENOENT/EACCES and + // other launch failures leave this server alive and return a useful + // error. The shim itself waits until after the acknowledgement and + // deferred exit before binding the replacement server. + yield* host + .spawnDetached("/bin/sh", [ + "-c", + 'sleep 3; exec "$@"', + "t3-self-update", + host.execPath, + runtimePaths.entryPath, + ...host.cliArgs, + ]) + .pipe( + Effect.mapError((cause) => + failWith("Could not start the replacement t3 process.", cause), + ), + ); + yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion }); + yield* scheduleRestart( + Effect.try({ + try: () => host.exitProcess(), + catch: (cause) => failWith("Could not exit the replaced t3 process.", cause), + }).pipe( + Effect.catch((error) => + Effect.logError("Server self-update could not exit the replaced process.").pipe( + Effect.annotateLogs({ targetVersion, error: error.reason }), + Effect.ensuring(Ref.set(inFlight, false)), + ), + ), + ), + ); + } + + return { targetVersion, method: activeMethod }; + }).pipe(Effect.onError(() => Ref.set(inFlight, false))); + }); + + return ServerSelfUpdate.of({ update }); +}); + +export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index df57ab4ea45..639811b024e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service< readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; @@ -199,6 +200,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( desktopBootstrapToken: undefined, staticDir: undefined, devUrl, + devAllowedOrigins: [], noBrowser: false, startupPresentation: "browser", }); diff --git a/apps/server/src/diagnostics/StockQuote.test.ts b/apps/server/src/diagnostics/StockQuote.test.ts index 103f861fbdf..4da1ad4830f 100644 --- a/apps/server/src/diagnostics/StockQuote.test.ts +++ b/apps/server/src/diagnostics/StockQuote.test.ts @@ -30,7 +30,10 @@ describe("StockQuote", () => { }); describe("parseCboe", () => { - const payload = (data: Record) => ({ timestamp: "2026-07-10T12:00:00Z", data }); + const payload = (data: Record) => ({ + timestamp: "2026-07-10T12:00:00Z", + data, + }); it("reads price, percent change, symbol and USD currency directly", () => { const quote = parseCboe( @@ -95,10 +98,7 @@ describe("StockQuote", () => { }); it("returns a null change when no previous close is available", () => { - const quote = parseYahooChart( - chart({ symbol: "SPY", regularMarketPrice: 100 }), - CAPTURED_AT, - ); + const quote = parseYahooChart(chart({ symbol: "SPY", regularMarketPrice: 100 }), CAPTURED_AT); expect(quote?.changePercent).toBeNull(); }); @@ -112,7 +112,8 @@ describe("StockQuote", () => { describe("parseStooqCsv", () => { it("reads the close price from the data row (no percent change)", () => { - const csv = "Symbol,Date,Time,Open,High,Low,Close,Volume\nSPY.US,2026-07-09,22:00:00,611,613,610,612.34,1000000"; + const csv = + "Symbol,Date,Time,Open,High,Low,Close,Volume\nSPY.US,2026-07-09,22:00:00,611,613,610,612.34,1000000"; const quote = parseStooqCsv(csv, CAPTURED_AT); expect(quote?.symbol).toBe("SPY.US"); expect(quote?.price).toBe(612.34); @@ -121,7 +122,8 @@ describe("StockQuote", () => { }); it("returns null when Stooq reports N/D for an unknown symbol", () => { - const csv = "Symbol,Date,Time,Open,High,Low,Close,Volume\nZZZZ.US,N/D,N/D,N/D,N/D,N/D,N/D,N/D"; + const csv = + "Symbol,Date,Time,Open,High,Low,Close,Volume\nZZZZ.US,N/D,N/D,N/D,N/D,N/D,N/D,N/D"; expect(parseStooqCsv(csv, CAPTURED_AT)).toBeNull(); }); diff --git a/apps/server/src/diagnostics/StockQuote.ts b/apps/server/src/diagnostics/StockQuote.ts index 52116be23e6..93ebf38b867 100644 --- a/apps/server/src/diagnostics/StockQuote.ts +++ b/apps/server/src/diagnostics/StockQuote.ts @@ -97,7 +97,8 @@ export function parseYahooChart(body: unknown, capturedAt: number): StockQuote | if (price === null) { return null; } - const previousClose = asFiniteNumber(meta.previousClose) ?? asFiniteNumber(meta.chartPreviousClose); + const previousClose = + asFiniteNumber(meta.previousClose) ?? asFiniteNumber(meta.chartPreviousClose); const changePercent = previousClose !== null && previousClose !== 0 ? ((price - previousClose) / previousClose) * 100 @@ -307,7 +308,10 @@ export const readStockQuote = ( const value = freshQuote ?? previousValue; writeCache(normalized, { value, freshUntilMs }); return value; - }).pipe(Effect.withSpan("readStockQuote"), Effect.orElseSucceed(() => null)); + }).pipe( + Effect.withSpan("readStockQuote"), + Effect.orElseSucceed(() => null), + ); /** Test-only: drop the process-wide cache so cases start clean. */ export function clearStockQuoteCacheForTests(): void { diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 2c439be1ea0..d4d921fb851 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -46,6 +46,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { desktopBootstrapToken: undefined, staticDir: undefined, devUrl: undefined, + devAllowedOrigins: [], noBrowser: false, startupPresentation: "browser", } satisfies ServerConfig.ServerConfig["Service"]; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1c0d34ea5bc..0eaf5a7c16a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; +import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; @@ -124,6 +125,9 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -136,6 +140,9 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + threadSettlement: true, + threadSnooze: true, + ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..256f10fb3b8 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -8,6 +8,7 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -50,6 +51,8 @@ interface FakeGhScenario { }; repositoryCloneUrls?: Record; failWith?: GitHubCli.GitHubCliError; + /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ + failAfterCalls?: number; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -382,7 +385,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const args = [...input.args]; ghCalls.push(args.join(" ")); - if (scenario.failWith) { + if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { return Effect.fail(scenario.failWith); } @@ -1336,6 +1339,230 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status keeps the last known PR when a later lookup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-sticky", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // An explicit invalidation (user refresh, git action) bypasses the PR + // cache and forces a live lookup — which now fails. The badge must keep + // the last known PR instead of blanking out. + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(214); + }), + ); + + it.effect( + "status does not reuse a stale PR after the branch is retargeted to a different upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-retarget"]); + + const originRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originRemote]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-retarget"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-retarget", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // Retarget the branch to a different remote/upstream (e.g. the PR was + // reopened against a fork). The previously cached PR belonged to the + // old upstream and must not be shown against the new one. + const forkRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork", forkRemote]); + yield* runGit(repoDir, ["push", "fork", "feature/pr-retarget"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to=fork/feature/pr-retarget", + "feature/pr-retarget", + ]); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + + it.effect("status keeps the last known PR when the branch gains its first upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const existingPr = { + number: 215, + title: "Sticky first-push PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/215", + baseRefName: "main", + headRefName: "feature/pr-sticky-first-push", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(215); + + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(215); + }), + ); + + it.effect("status drops the last known PR when the tracked remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]); + + const existingPr = { + number: 216, + title: "Old remote PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/216", + baseRefName: "main", + headRefName: "feature/pr-repointed", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(216); + + const replacementRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]); + yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]); + yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + + it.effect("status keeps the last known PR when the current remote URL can't be resolved", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-config-hiccup"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-config-hiccup"]); + + const existingPr = { + number: 217, + title: "Config hiccup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/217", + baseRefName: "main", + headRefName: "feature/pr-config-hiccup", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(217); + + // `remote.origin.url` reads go through readConfigValueNullable, which + // maps ANY failed read (a real "no remote configured" state or a + // transient git-config hiccup) to null the same way. Unsetting the + // key here reproduces that ambiguity without touching branch + // tracking (refs/remotes/origin/* and branch..remote are + // untouched) — the remote identity has not actually changed, so the + // sticky PR must survive even though the current lookup can no + // longer resolve a remote URL to compare against. + yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(217); + }), + ); + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2187,6 +2414,123 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); + it.effect( + "does not reuse a cross-repo PR when GitHub omits head identity metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequenceByHeadSelector: { + "octocat:statemachine": [ + `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, + `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, + ], + "fork-seed:statemachine": ["[]"], + statemachine: ["[]"], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(142); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true); + }), + 20_000, + ); + + it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => + Effect.sync(() => { + const headContext = { + headBranch: "statemachine", + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + isCrossRepository: true, + }; + + expect( + GitManager.matchesBranchHeadContext( + { + number: 41, + title: "Same-repo PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(false); + + expect( + GitManager.matchesBranchHeadContext( + { + number: 142, + title: "Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(true); + }), + ); + + it.effect("accepts fork PR metadata when origin is the fork checkout remote", () => + Effect.sync(() => { + const headContext = { + headBranch: "t3code/git-audit-stability", + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + isCrossRepository: false, + }; + + expect( + GitManager.matchesBranchHeadContext( + { + number: 2284, + title: "Improve branch mismatch warnings", + url: "https://github.com/pingdotgg/t3code/pull/2284", + baseRefName: "main", + headRefName: "t3code/git-audit-stability", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + }, + headContext, + ), + ).toBe(true); + }), + ); + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..86e10d9f930 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -32,6 +32,7 @@ import { import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, + normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -95,6 +96,9 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; +const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const PR_LOOKUP_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -142,6 +146,7 @@ interface BranchHeadContext { headSelectors: ReadonlyArray; preferredHeadSelector: string; remoteName: string | null; + headRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -247,7 +252,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner( return `${ownerLogin}/${repositoryName}`; } -function matchesBranchHeadContext( +interface PullRequestHeadIdentity { + readonly repositoryNameWithOwner: string | null; + readonly ownerLogin: string | null; +} + +function resolveExpectedHeadIdentity( + headContext: Pick, +): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, @@ -258,44 +294,51 @@ function matchesBranchHeadContext( return false; } - const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( - headContext.headRepositoryNameWithOwner, - ); - const expectedHeadOwner = - normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(expectedHeadRepository); - const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), - ); - const prHeadOwner = - normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(prHeadRepository); + const expectedHead = resolveExpectedHeadIdentity(headContext); + const pullRequestHead = resolvePullRequestHeadIdentity(pr); - if (headContext.isCrossRepository) { - if (pr.isCrossRepository === false) { - return false; + if (expectedHead.repositoryNameWithOwner) { + if (pullRequestHead.repositoryNameWithOwner) { + if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { + return false; + } } - if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } + } + } + + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + } + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { return false; } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + if ( + (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && + !pullRequestHead.repositoryNameWithOwner && + !pullRequestHead.ownerLogin + ) { return false; } return true; } if (pr.isCrossRepository === true) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; + if ( + (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || + (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) + ) { + return false; + } } + return true; } @@ -768,6 +811,154 @@ export const make = Effect.gen(function* () { normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); + // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh + // on their own, slower cadence: ahead/behind counts stay fresh on every + // status poll while the PR association is re-fetched at most once per + // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump + // the epoch (invalidateStatus) to bypass the cache immediately. + const prLookupEpochByCwd = new Map(); + const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; + const bumpPrLookupEpoch = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.map((cacheKey) => { + prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); + }), + ); + // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the + // segments can contain a NUL byte, and refs are never empty, so "" decodes + // back to a null upstreamRef. + const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => + [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + const prLookupCache = yield* Cache.makeWith( + (key: string) => { + const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); + const details = { + branch, + upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, + }; + return resolveBranchHeadContext(cwd, details).pipe( + Effect.flatMap((headContext) => + findLatestPrForHeadContext(cwd, headContext).pipe( + Effect.map((latest) => ({ latest, headContext })), + ), + ), + ); + }, + { + capacity: PR_LOOKUP_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), + }, + ); + // A transient lookup failure (rate limit, network blip) must not clear an + // already-known PR badge, so the last successful answer per branch sticks + // around as the fallback. Keep the resolved head context with it so a + // branch retargeted to another remote/fork cannot inherit the old badge. + interface LastKnownPr { + readonly pr: ReturnType | null; + readonly upstreamRef: string | null; + readonly headBranch: string; + readonly remoteName: string | null; + readonly headRemoteUrlKey: string | null; + } + const lastKnownPrByBranchKey = new Map(); + const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => { + if ( + !lastKnownPrByBranchKey.has(branchKey) && + lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = lastKnownPrByBranchKey.keys().next().value; + if (oldestKey !== undefined) { + lastKnownPrByBranchKey.delete(oldestKey); + } + } + lastKnownPrByBranchKey.set(branchKey, entry); + }; + const resolveLastKnownPr = ( + branchKey: string, + current: Pick, + ): ReturnType | null => { + const lastKnown = lastKnownPrByBranchKey.get(branchKey); + if (!lastKnown) return null; + if (lastKnown.headBranch !== current.headBranch) { + return null; + } + + // The normalized URL catches both remote-alias changes and an existing + // alias being repointed. Both sides must be resolved before treating a + // mismatch as real: `readConfigValueNullable` swallows any git-config + // read failure into `null`, so a transient failure to resolve the + // *current* remote URL must read as "unknown", not as "no remote" — the + // latter would otherwise drop an already-known PR badge on every hiccup. + if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) { + return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; + } + + // If the remote URL can't be compared, fall back to the remote identity + // encoded by tracked branches — same "both sides known" requirement, for + // the same reason. A null-to-non-null transition (upstream/remoteName) + // is allowed because that is the expected first-push case. + if ( + lastKnown.upstreamRef !== null && + current.upstreamRef !== null && + lastKnown.remoteName !== null && + current.remoteName !== null + ) { + return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; + } + return lastKnown.pr; + }; + const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, + ) { + // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first + // `push -u`) must not orphan the fallback value for the same branch. + const branchKey = `${cwd}\u0000${details.branch}`; + return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + Effect.map(({ latest, headContext }) => { + if (!latest) return { pr: null, headContext }; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") { + return { pr: null, headContext }; + } + return { pr: toStatusPr(latest), headContext }; + }), + Effect.tap(({ pr, headContext }) => + Effect.sync(() => + rememberLastKnownPr(branchKey, { + pr, + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + Effect.map(({ pr }) => pr), + Effect.catch((error) => + Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( + Effect.annotateLogs({ + operation: "lookupStatusPr", + branch: details.branch, + errorTag: + typeof error === "object" && error !== null && "_tag" in error + ? String(error._tag) + : typeof error, + }), + Effect.andThen(resolveBranchHeadContext(cwd, details)), + Effect.map((headContext) => + resolveLastKnownPr(branchKey, { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + ), + ); + }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, options?: GitVcsDriver.GitRemoteStatusOptions, @@ -781,19 +972,11 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* findLatestPr(cwd, { + ? yield* lookupStatusPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => { - if (!latest) return null; - // On the default branch, only surface open PRs. - // Merged/closed matches are usually reverse-merge history, not the thread's PR context. - if (details.isDefaultBranch && latest.state !== "open") return null; - return toStatusPr(latest); - }), - Effect.orElseSucceed(() => null), - ) + isDefaultBranch: details.isDefaultBranch, + }) : null; return { @@ -837,6 +1020,7 @@ export const make = Effect.gen(function* () { ) { if (!remoteName) { return { + remoteUrlKey: null, repositoryNameWithOwner: null, ownerLogin: null, }; @@ -845,6 +1029,7 @@ export const make = Effect.gen(function* () { const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { + remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), }; @@ -915,6 +1100,9 @@ export const make = Effect.gen(function* () { preferredHeadSelector: ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, remoteName, + headRemoteUrlKey: + remoteRepository.remoteUrlKey ?? + (remoteName === null ? originRepository.remoteUrlKey : null), headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -960,11 +1148,10 @@ export const make = Effect.gen(function* () { return null; }); - const findLatestPr = Effect.fn("findLatestPr")(function* ( + const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + headContext: BranchHeadContext, ) { - const headContext = yield* resolveBranchHeadContext(cwd, details); const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { @@ -991,7 +1178,6 @@ export const make = Effect.gen(function* () { } return parsed[0] ?? null; }); - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, @@ -1442,6 +1628,10 @@ export const make = Effect.gen(function* () { function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); + // Full invalidation is the explicit-freshness path (git actions, user + // refresh); it also bypasses the slow PR-lookup cache. The periodic + // status poll only invalidates local/remote and keeps the PR cache warm. + yield* bumpPrLookupEpoch(cwd); }, ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b9bb40f372d..77eef955d55 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -4,6 +4,7 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; +import { isDevProxiedPath } from "@t3tools/shared/devProxy"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -48,9 +49,17 @@ export const browserApiCorsLayer = Layer.unwrap( const devOrigin = config.devUrl?.origin; // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. + // + // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second + // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies + // through Vite and is same-origin (no preflight at all), so this is a + // safety net for the desktop renderer and any direct-to-backend caller. return HttpRouter.cors({ ...(devOrigin - ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true } + ? { + allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins], + credentials: true, + } : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, @@ -216,6 +225,10 @@ export const staticAndDevRouteLayer = HttpRouter.add( } const config = yield* ServerConfig.ServerConfig; + if (config.devUrl && isDevProxiedPath(url.value.pathname)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index ffda427d815..5bd665e5179 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -174,7 +174,7 @@ it.effect("does not let an older response replace a newer explicit tab target", ), ); -it.effect("does not replace the default tab with a globally stopped recording tab", () => +it.effect("tracks the tab returned by a targeted recording stop", () => Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; @@ -203,7 +203,7 @@ it.effect("does not replace the default tab with a globally stopped recording ta yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId); + expect(routedRequests.at(-1)?.tabId).toBe(recordingTabId); }), ), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 7ed77aabdf1..5a952803033 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -548,8 +548,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); }); const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); - // A stop artifact identifies the globally recorded tab, not the caller's browsing target. - const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result); + const responseTabId = readResultTabId(result); const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; if (resultTabId === undefined) return result; const assignmentKey = hostAssignmentKey(input.scope); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts new file mode 100644 index 00000000000..93985fc9d4c --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizePreviewOpenInput } from "./handlers.ts"; + +describe("normalizePreviewOpenInput", () => { + it("opens the inline preview and reuses the current tab by default", () => { + expect(normalizePreviewOpenInput({})).toEqual({ + open: true, + reuseExistingTab: true, + show: true, + }); + }); + + it("preserves an explicit background-only opt-out", () => { + expect(normalizePreviewOpenInput({ open: false })).toEqual({ + open: false, + reuseExistingTab: true, + show: false, + }); + }); + + it("supports show as a legacy alias while preferring open", () => { + expect(normalizePreviewOpenInput({ show: false })).toEqual({ + open: false, + reuseExistingTab: true, + show: false, + }); + expect(normalizePreviewOpenInput({ open: true, show: false })).toEqual({ + open: true, + reuseExistingTab: true, + show: true, + }); + }); +}); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 64d6ba02b1d..1c7ff6f9cd9 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,9 +1,11 @@ import * as Effect from "effect/Effect"; import type { PreviewAutomationOperation, + PreviewAutomationOpenInput, PreviewAutomationRecordingArtifact, PreviewAutomationRecordingStatus, PreviewAutomationResizeResult, + PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, PreviewTabId, @@ -13,6 +15,18 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; +export function normalizePreviewOpenInput( + input: PreviewAutomationOpenInput, +): PreviewAutomationOpenInput { + const open = input.open ?? input.show ?? true; + return { + ...input, + open, + show: open, + reuseExistingTab: input.reuseExistingTab ?? true, + }; +} + const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( operation: PreviewAutomationOperation, input: unknown, @@ -49,15 +63,13 @@ const invokeTargeted = ( const handlers = { preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => - invokeTargeted("open", { - ...input, - show: input.show ?? true, - reuseExistingTab: input.reuseExistingTab ?? true, - }), + invokeTargeted("open", normalizePreviewOpenInput(input)), preview_navigate: (input) => invokeTargeted("navigate", input, input.timeoutMs), preview_resize: (input) => invokeTargeted("resize", input, input.timeoutMs), + preview_set_appearance: (input) => + invokeTargeted("setColorScheme", input), preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), preview_click: (input) => invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index c729fc20ece..a94d2b056f7 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -10,6 +10,8 @@ import { PreviewAutomationResizeInput, PreviewAutomationResizeResult, PreviewAutomationScrollInput, + PreviewAutomationSetColorSchemeInput, + PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, PreviewAutomationTabTargetInput, @@ -52,7 +54,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { export const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: - "Show and initialize a collaborative browser tab. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", + "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, @@ -86,6 +88,19 @@ export const PreviewResizeTool = safeBrowserTool( .annotate(Tool.Idempotent, true), ); +export const PreviewSetAppearanceTool = safeBrowserTool( + Tool.make("preview_set_appearance", { + description: + "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.", + parameters: PreviewAutomationSetColorSchemeInput, + success: PreviewAutomationSetColorSchemeResult, + failure: PreviewAutomationError, + dependencies, + }) + .annotate(Tool.Title, "Set preview appearance") + .annotate(Tool.Idempotent, true), +); + export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: @@ -176,7 +191,8 @@ export const PreviewRecordingStartTool = safeBrowserTool( export const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { - description: "Stop the active browser recording and save it as a local evidence artifact.", + description: + "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, @@ -189,6 +205,7 @@ export const PreviewToolkit = Toolkit.make( PreviewOpenTool, PreviewNavigateTool, PreviewResizeTool, + PreviewSetAppearanceTool, PreviewSnapshotTool, PreviewClickTool, PreviewTypeTool, @@ -205,6 +222,7 @@ export const PreviewStandardToolkit = Toolkit.make( PreviewOpenTool, PreviewNavigateTool, PreviewResizeTool, + PreviewSetAppearanceTool, PreviewClickTool, PreviewTypeTool, PreviewPressTool, diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts new file mode 100644 index 00000000000..5fd0cc8984b --- /dev/null +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -0,0 +1,227 @@ +import type { + OrchestrationEvent, + OrchestrationThreadActivity, + OrchestrationThreadDetailSnapshot, +} from "@t3tools/contracts"; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asTrimmedString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function pushChangedFile(target: string[], seen: Set, value: unknown): void { + const normalized = asTrimmedString(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + target.push(normalized); +} + +function collectChangedFiles( + value: unknown, + target: string[], + seen: Set, + depth: number, +): void { + if (depth > 4 || target.length >= 12) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectChangedFiles(entry, target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } + return; + } + + const record = asRecord(value); + if (!record) { + return; + } + + pushChangedFile(target, seen, record.path); + pushChangedFile(target, seen, record.filePath); + pushChangedFile(target, seen, record.relativePath); + pushChangedFile(target, seen, record.filename); + pushChangedFile(target, seen, record.newPath); + pushChangedFile(target, seen, record.oldPath); + + for (const nestedKey of [ + "item", + "result", + "input", + "data", + "changes", + "files", + "edits", + "patch", + "patches", + "operations", + ]) { + if (!(nestedKey in record)) { + continue; + } + collectChangedFiles(record[nestedKey], target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } +} + +function projectCommandData(data: Record): Record | undefined { + const item = asRecord(data.item); + if (!item) { + return undefined; + } + + const projectedItem: Record = {}; + if ("command" in item) { + projectedItem.command = item.command; + } + + const input = asRecord(item.input); + if (input && "command" in input) { + projectedItem.input = { command: input.command }; + } + + const result = asRecord(item.result); + if (result && "command" in result) { + projectedItem.result = { command: result.command }; + } + + return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; +} + +function summarizeToolTextOutput(value: string): string | null { + const lines: string[] = []; + for (const rawLine of value.split(/\r?\n/u)) { + const line = rawLine.replace(/\s+/g, " ").trim(); + if (line.length > 0) { + lines.push(line); + } + } + + const firstLine = lines.find((line) => line !== "```"); + if (firstLine) { + return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; + } + if (lines.length > 1) { + return `${lines.length.toLocaleString()} lines`; + } + return null; +} + +function projectRawOutput(value: unknown): Record | undefined { + const rawOutput = asRecord(value); + if (!rawOutput) { + return undefined; + } + + if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) { + return { + totalFiles: rawOutput.totalFiles, + ...(rawOutput.truncated === true ? { truncated: true } : {}), + }; + } + + const content = asTrimmedString(rawOutput.content); + if (content) { + const summary = summarizeToolTextOutput(content); + return summary ? { content: summary } : undefined; + } + + const stdout = asTrimmedString(rawOutput.stdout); + if (stdout) { + const summary = summarizeToolTextOutput(stdout); + return summary ? { content: summary } : undefined; + } + + return undefined; +} + +/** + * Removes activity payload fields that no current client reads while retaining + * the full payload in persistence and the event store. + */ +export function projectActivityPayload( + activity: OrchestrationThreadActivity, +): OrchestrationThreadActivity { + const payload = asRecord(activity.payload); + const data = asRecord(payload?.data); + if (!payload || !data || payload.itemType === "mcp_tool_call") { + return activity; + } + + const projectedData: Record = {}; + const item = projectCommandData(data); + if (item) { + projectedData.item = item; + } + if ("command" in data) { + projectedData.command = data.command; + } + + const changedFiles: string[] = []; + collectChangedFiles(data, changedFiles, new Set(), 0); + if (changedFiles.length > 0) { + // Both clients discover file names by walking objects with path-like keys. + projectedData.files = changedFiles.map((path) => ({ path })); + } + + if ("toolCallId" in data) { + projectedData.toolCallId = data.toolCallId; + } + if ("kind" in data) { + projectedData.kind = data.kind; + } + + const rawOutput = projectRawOutput(data.rawOutput); + if (rawOutput) { + projectedData.rawOutput = rawOutput; + } + + return { + ...activity, + payload: { + ...payload, + data: projectedData, + }, + }; +} + +export function projectThreadDetailSnapshot( + snapshot: OrchestrationThreadDetailSnapshot, +): OrchestrationThreadDetailSnapshot { + return { + ...snapshot, + thread: { + ...snapshot.thread, + activities: snapshot.thread.activities.map(projectActivityPayload), + }, + }; +} + +export function projectActivityEvent(event: OrchestrationEvent): OrchestrationEvent { + if (event.type !== "thread.activity-appended") { + return event; + } + return { + ...event, + payload: { + ...event.payload, + activity: projectActivityPayload(event.payload.activity), + }, + }; +} diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b00c00e0d3f..731002ea783 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..926182a3ef0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -171,6 +171,70 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { for (const row of stateRows) { assert.equal(row.lastAppliedSequence, 3); } + + // Settled lifecycle through the DB pipeline: thread.settled writes the + // override + timestamp, thread.unsettled(user) flips to the active pin. + yield* eventStore.append({ + type: "thread.settled", + eventId: EventId.make("evt-settle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("cmd-settle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-settle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const settledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(settledRows, [ + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + ]); + + yield* eventStore.append({ + type: "thread.unsettled", + eventId: EventId.make("evt-unsettle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: CommandId.make("cmd-unsettle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-unsettle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + reason: "user", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const unsettledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); }), ); }); @@ -2400,6 +2464,74 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ); }); +it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test-"))( + "OrchestrationProjectionPipeline pending turn cleanup", + (it) => { + it.effect("clears pending turn starts when startup reaches a terminal session state", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + + for (const [index, status] of (["error", "interrupted", "stopped"] as const).entries()) { + const threadId = ThreadId.make(`thread-terminal-${status}`); + const requestedAt = `2026-02-26T14:00:0${index}.000Z`; + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-terminal-pending-${status}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: requestedAt, + commandId: CommandId.make(`cmd-terminal-pending-${status}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-terminal-pending-${status}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-terminal-${status}`), + runtimeMode: "approval-required", + createdAt: requestedAt, + }, + }); + yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make(`evt-terminal-session-${status}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: requestedAt, + commandId: CommandId.make(`cmd-terminal-session-${status}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-terminal-session-${status}`), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status, + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: status === "error" ? "startup failed" : null, + updatedAt: requestedAt, + }, + }, + }); + } + + yield* projectionPipeline.bootstrap; + + const pendingRows = yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE turn_id IS NULL + AND state = 'pending' + `; + assert.deepEqual(pendingRows, []); + }), + ); + }, +); + it.effect("restores pending turn-start metadata across projection pipeline restart", () => Effect.gen(function* () { const { dbPath } = yield* ServerConfig; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f5a18d74fca..50912af62d8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,6 +607,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -647,6 +651,70 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.settled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsettled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.snoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: event.payload.snoozedUntil, + snoozedAt: event.payload.snoozedAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsnoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: null, + snoozedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -1061,6 +1129,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { + if ( + event.payload.session.status === "error" || + event.payload.session.status === "stopped" || + event.payload.session.status === "interrupted" + ) { + yield* projectionTurnRepository.deletePendingTurnStartByThreadId({ + threadId: event.payload.threadId, + }); + } // Leaving the "running" session status is the turn-end signal: // settle still-running turns so their duration reflects the whole // turn rather than the last assistant message. diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 31ab9e80a6d..0668bfa6dc2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,6 +308,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [ { @@ -418,6 +422,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", @@ -564,6 +572,115 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-settled-test', + 'Settled Test', + '/tmp/settled-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + settled_override, + settled_at, + deleted_at + ) + VALUES ( + 'thread-settled', + 'project-settled-test', + 'Settled Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:05.000Z', + NULL, + 'settled', + '2026-04-06T00:00:04.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + `; + + // Settled ≠ archived: the thread must appear in the LIVE shell + // snapshot, carrying its settlement fields through the row aliases. + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-settled")], + ); + assert.equal(shellSnapshot.threads[0]?.settledOverride, "settled"); + assert.equal(shellSnapshot.threads[0]?.settledAt, "2026-04-06T00:00:04.000Z"); + + // And the full command read model carries them too. + const readModel = yield* snapshotQuery.getCommandReadModel(); + const thread = readModel.threads.find( + (candidate) => candidate.id === ThreadId.make("thread-settled"), + ); + assert.equal(thread?.settledOverride, "settled"); + assert.equal(thread?.settledAt, "2026-04-06T00:00:04.000Z"); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4cea999de43..0e5e9da90e0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,6 +334,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -364,6 +368,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -396,6 +404,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -760,6 +772,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1194,6 +1210,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1392,6 +1412,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1521,6 +1545,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1657,6 +1685,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1899,6 +1931,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1995,6 +2031,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 07a90972d6e..b25209d49ed 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -23,6 +23,7 @@ import { TurnId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -146,6 +147,9 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly startSessionEffect?: ( + session: ProviderSession, + ) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -160,6 +164,7 @@ describe("ProviderCommandReactor", () => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }; + const startSessionEffect = input?.startSessionEffect; const startSession = vi.fn((_: unknown, input: unknown) => { const sessionIndex = nextSessionIndex++; const resumeCursor = @@ -213,8 +218,13 @@ describe("ProviderCommandReactor", () => { createdAt: now, updatedAt: now, }; - runtimeSessions.push(session); - return Effect.succeed(session); + return (startSessionEffect?.(session) ?? Effect.succeed(session)).pipe( + Effect.tap((startedSession) => + Effect.sync(() => { + runtimeSessions.push(startedSession); + }), + ), + ); }); const sendTurn = vi.fn((_: unknown) => Effect.succeed({ @@ -484,9 +494,120 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.status).toBe("starting"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect("projects starting before a slow provider session finishes", () => + Effect.gen(function* () { + const releaseStart = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-slow-provider"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-slow-provider"), + role: "user", + text: "start slowly", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); + const duringStartup = yield* Effect.promise(() => harness.readModel()); + expect( + duringStartup.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status, + ).toBe("starting"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + yield* Deferred.succeed(releaseStart, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + }), + ); + + effectIt.effect("settles a failed provider startup and allows a clean retry", () => + Effect.gen(function* () { + let failStartup = true; + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + failStartup + ? Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.start", + detail: "deterministic startup failure", + }), + ) + : Effect.succeed(session), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-failure"), + role: "user", + text: "fail once", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status === "error" + ); + }), + ); + let readModel = yield* Effect.promise(() => harness.readModel()); + let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.lastError).toContain("deterministic startup failure"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + failStartup = false; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-retry"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-retry"), + role: "user", + text: "retry", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + readModel = yield* Effect.promise(() => harness.readModel()); + thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.lastError).toBeNull(); + }), + ); + it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f41251e6244..103ae6ad27e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -311,15 +311,20 @@ const make = Effect.gen(function* () { readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); - const session = thread?.session; - if (!session) { + if (!thread) { return; } + const session = thread.session; yield* setThreadSession({ threadId: input.threadId, session: { - ...session, - status: session.status === "stopped" ? "stopped" : "ready", + ...(session ?? { + threadId: input.threadId, + providerName: null, + providerInstanceId: thread.modelSelection.instanceId, + runtimeMode: thread.runtimeMode, + }), + status: session?.status === "stopped" ? "stopped" : "error", activeTurnId: null, lastError: input.detail, updatedAt: input.createdAt, @@ -377,6 +382,7 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; + readonly pendingTurnStart?: boolean; }, ) { const thread = yield* resolveThread(threadId); @@ -451,6 +457,22 @@ const make = Effect.gen(function* () { }); } const preferredProvider: ProviderDriverKind = desiredDriverKind; + if (options?.pendingTurnStart === true && thread.session?.status !== "running") { + yield* setThreadSession({ + threadId, + session: { + threadId, + status: "starting", + providerName: activeSession?.provider ?? preferredProvider, + providerInstanceId: activeSession?.providerInstanceId ?? desiredInstanceId, + runtimeMode: desiredRuntimeMode, + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + } if (thread.session !== null) { yield* rejectStartedThreadModelChangeIfRequired({ threadId, @@ -521,7 +543,10 @@ const make = Effect.gen(function* () { threadId, session: { threadId, - status: mapProviderSessionStatusToOrchestrationStatus(session.status), + status: + options?.pendingTurnStart === true && session.status === "ready" + ? "starting" + : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, providerInstanceId: session.providerInstanceId, runtimeMode: desiredRuntimeMode, @@ -750,11 +775,16 @@ const make = Effect.gen(function* () { new Error(`Thread '${input.threadId}' was not found in read model.`), ); } - yield* ensureSessionForThread( - input.threadId, - input.createdAt, - input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, - ); + yield* ensureSessionForThread(input.threadId, input.createdAt, { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + pendingTurnStart: true, + }); + // Upstream also writes `threadModelSelections` here. This fork deliberately + // does not: the deferred model-change recycle decides whether a pending + // recycle is still needed by comparing it against this map (see the + // Equal.equals check in the turn-completed flush), so recording the + // selection eagerly at turn start makes every pending recycle look already + // applied and it never flushes. Keep upstream's `pendingTurnStart` flag. const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 303c5d8dce7..0002dfb8ca3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -30,6 +30,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import { it as effectIt } from "@effect/vitest"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -494,6 +495,185 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBeNull(); }); + effectIt.effect( + "keeps a reconnecting pending turn starting while ready clears stale active state", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const staleTurnId = asTurnId("turn-stale-before-reconnect"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-pending-reconnect"), + threadId, + message: { + messageId: MessageId.make("message-pending-reconnect"), + role: "user", + text: "resume after reconnect", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-starting-pending-reconnect"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: staleTurnId, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-ready-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + payload: { state: "ready" }, + }); + + let thread = yield* Effect.promise(() => + waitForThread( + harness.readModel, + (entry) => entry.session?.status === "starting" && entry.session.activeTurnId === null, + ), + ); + expect(thread.session?.status).toBe("starting"); + expect(thread.session?.activeTurnId).toBeNull(); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:03.000Z", + }); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + )!; + expect(thread.session?.status).toBe("starting"); + expect(thread.session?.activeTurnId).toBeNull(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + turnId: asTurnId("turn-after-reconnect"), + createdAt: "2026-01-01T00:00:04.000Z", + }); + thread = yield* Effect.promise(() => + waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "running" && + entry.session.activeTurnId === asTurnId("turn-after-reconnect"), + ), + ); + expect(thread.session?.status).toBe("running"); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started-duplicate-midturn"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:05.000Z", + }); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + )!; + expect(thread.session?.status).toBe("running"); + expect(thread.session?.activeTurnId).toBe(asTurnId("turn-after-reconnect")); + }), + ); + + effectIt.effect("keeps an aborted pending start stopped across duplicate exit events", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const stoppedAt = "2026-01-01T00:00:02.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-stop"), + threadId, + message: { + messageId: MessageId.make("message-before-stop"), + role: "user", + text: "stop this startup", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-starting-before-stop"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-stop-pending-start"), + threadId, + session: { + threadId, + status: "stopped", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: stoppedAt, + }, + createdAt: stoppedAt, + }); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-session-exited-after-stop"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:03.000Z", + }); + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-duplicate-session-exited-after-stop"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:04.000Z", + }); + + yield* Effect.promise(() => harness.drain()); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.activeTurnId).toBeNull(); + }), + ); + it("does not clear active turn when session/thread started arrives mid-turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 09566feb2b2..a8a51b30260 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1308,6 +1308,11 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }); + const hasPendingTurnStart = + Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); @@ -1322,11 +1327,7 @@ const make = Effect.gen(function* () { const conflictingTurnStartIsPendingTurnStart = event.type === "turn.started" && conflictsWithActiveTurn ? sameId(yield* getExpectedProviderTurnIdForThread(thread.id), eventTurnId) && - Option.isSome( - yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }), - ) + Option.isSome(pendingTurnStart) : false; const shouldApplyThreadLifecycle = (() => { @@ -1370,8 +1371,10 @@ const make = Effect.gen(function* () { ) { const status = (() => { switch (event.type) { - case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "session.state.changed": { + const runtimeStatus = orchestrationSessionStatusFromRuntimeState(event.payload.state); + return hasPendingTurnStart && runtimeStatus === "ready" ? "starting" : runtimeStatus; + } case "turn.started": return "running"; case "session.exited": @@ -1383,8 +1386,8 @@ const make = Effect.gen(function* () { case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an - // active turn; preserve turn-running state in that case. - return activeTurnId !== null ? "running" : "ready"; + // active or pending turn; preserve that lifecycle state. + return activeTurnId !== null ? "running" : hasPendingTurnStart ? "starting" : "ready"; } })(); const nextActiveTurnId = @@ -1392,7 +1395,10 @@ const make = Effect.gen(function* () { ? (eventTurnId ?? null) : event.type === "turn.completed" || event.type === "session.exited" ? null - : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn(status) + : event.type === "session.state.changed" && + !sessionStatusAllowsActiveTurn( + orchestrationSessionStatusFromRuntimeState(event.payload.state), + ) ? null : activeTurnId; const lastError = diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..3b558d24739 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,11 +4,15 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, + ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, + ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, + ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, + ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -29,11 +33,15 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; +export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; +export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; +export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; +export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..9531cd5c3af 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts new file mode 100644 index 00000000000..73f1cbf9127 --- /dev/null +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -0,0 +1,521 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel( + settledOverride: OrchestrationThread["settledOverride"], + archivedAt: string | null = null, + session: OrchestrationSession | null = null, + activities: OrchestrationThread["activities"] = [], + messages: OrchestrationThread["messages"] = [], +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + settledOverride, + settledAt: settledOverride === "settled" ? SETTLED_AT : null, + deletedAt: null, + messages, + proposedPlans: [], + activities, + checkpoints: [], + session, + }, + ], + updatedAt: NOW, + }; +} + +function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { + return { + threadId: ThreadId.make("thread-1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("settles active threads and re-emits idempotently for settled ones", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); + } + + // Already settled: the engine rejects zero-event commands, so idempotency + // is by re-emission — preserving the original settledAt. + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled"), + }); + const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(reEmitEvents).toHaveLength(1); + expect(reEmitEvents[0]?.type).toBe("thread.settled"); + if (reEmitEvents[0]?.type === "thread.settled") { + expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + // updatedAt must NOT rewind to the historical settledAt: sorting and + // relative-time labels key on it. + expect(reEmitEvents[0].payload.updatedAt).not.toBe(SETTLED_AT); + } + }), + ); + + it.effect("rejects settling a thread with a live session", () => + Effect.gen(function* () { + for (const status of ["starting", "running"] as const) { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make(`cmd-settle-live-${status}`), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession(status)), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + } + // Stopped/error sessions are settleable — only live work is protected. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stopped"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession("stopped")), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling a thread with an open approval or user-input request", () => + Effect.gen(function* () { + const requestActivity = (kind: string, requestId: string, at: string) => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId }, + turnId: null, + createdAt: at, + }) as OrchestrationThread["activities"][number]; + + // Open approval request: settle rejected. + const openError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + ]), + }).pipe(Effect.flip); + expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + + // Same request later resolved: settleable again. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-resolved"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + requestActivity("approval.resolved", "req-1", NOW), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // Open user-input request: also rejected. + const inputError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending-input"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("user-input.requested", "req-2", NOW), + ]), + }).pipe(Effect.flip); + expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("clears an open request when its respond failure marks it stale", () => + Effect.gen(function* () { + const activity = ( + kind: string, + requestId: string, + payload: Record, + ): OrchestrationThread["activities"][number] => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId, ...payload }, + turnId: null, + createdAt: NOW, + }) as OrchestrationThread["activities"][number]; + + // Stale-failure detail clears the request — mirrors the projection's + // pending accounting, which is what the client's canSettle sees. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stale-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-1", {}), + activity("provider.approval.respond.failed", "req-1", { + detail: "Unknown pending approval request req-1", + }), + activity("user-input.requested", "req-2", {}), + activity("provider.user-input.respond.failed", "req-2", { + detail: "stale pending user-input request req-2", + }), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // A non-stale respond failure (transient provider error) keeps the + // request open: the user can retry, so it is still blocked-on-you. + const stillOpen = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-transient-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-3", {}), + activity("provider.approval.respond.failed", "req-3", { + detail: "provider connection reset", + }), + ]), + }).pipe(Effect.flip); + expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("bounds the queued-turn grace window against client clock skew", () => + Effect.gen(function* () { + const userMessage = (createdAt: string): OrchestrationThread["messages"][number] => ({ + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }); + + // The decider's clock is the Effect test clock, pinned to the epoch: + // timestamps here are relative to 1970-01-01T00:00:00.000Z. + + // Within the grace window: genuinely queued, settle rejected. + const queuedError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-queued"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), + }).pipe(Effect.flip); + expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + + // Message timestamp far in the FUTURE (client clock ahead of server): + // a negative age must not read as queued forever — past the grace + // bound in either direction the thread is settleable. + const skewed = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-skewed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1970-01-01T01:00:00.000Z")]), + }); + const skewedEvents = Array.isArray(skewed) ? skewed : [skewed]; + expect(skewedEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling and unsettling archived threads", () => + Effect.gen(function* () { + const settleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, NOW), + }).pipe(Effect.flip); + expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); + + const unsettleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-archived"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled", NOW), + }).pipe(Effect.flip); + expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => + Effect.gen(function* () { + const userEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled"), + }); + const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; + expect(userEvents).toHaveLength(1); + expect(userEvents[0]?.type).toBe("thread.unsettled"); + if (userEvents[0]?.type === "thread.unsettled") { + expect(userEvents[0].payload.reason).toBe("user"); + } + + // Re-dispatching against the already-reached state re-emits rather than + // producing zero events (the engine rejects empty commands). + const userAgain = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user-again"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("active"), + }); + const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; + expect(userAgainEvents).toHaveLength(1); + expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); + }), + ); + + it.effect("prepends activity unsets for turn starts and live session updates", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), + session: makeSession("running"), + createdAt: NOW, + }, + // A keep-active pin is also an override: real activity clears it + // back to neutral so auto-settle can apply again later. + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.session-set", + ]); + }), + ); + + it.effect("clears a keep-active pin on real activity", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-active-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-active"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + // The pin exists to suppress AUTO-settle, not to survive real work: + // activity resets it to neutral, restoring the default lifecycle. + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const activityResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-active-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-active"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; + expect(activityEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + }), + ); + + it.effect("does not unsettle for session stop/error status writes", () => + Effect.gen(function* () { + for (const status of ["stopped", "error", "ready", "idle"] as const) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-session-${status}`), + threadId: ThreadId.make("thread-1"), + session: makeSession(status), + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); + } + }), + ); + + it.effect("unsettles for approval and user-input activities but not others", () => + Effect.gen(function* () { + const approvalResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; + expect(approvalEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + + const routineResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-routine"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "info", + kind: "tool.completed", + summary: "Tool completed", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; + expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts new file mode 100644 index 00000000000..1012240b18a --- /dev/null +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -0,0 +1,286 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The decider's clock is the Effect test clock, pinned to the epoch, so +// "future" wake times are relative to 1970-01-01T00:00:00.000Z. +const FUTURE_WAKE = "1970-01-02T09:00:00.000Z"; +const PAST_WAKE = "1969-12-31T09:00:00.000Z"; +const SNOOZED_AT = "1969-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly snoozedUntil?: string | null; + readonly snoozedAt?: string | null; + readonly archivedAt?: string | null; + readonly activities?: OrchestrationThread["activities"]; + readonly messages?: OrchestrationThread["messages"]; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + snoozedUntil: input.snoozedUntil ?? null, + snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), + deletedAt: null, + messages: input.messages ?? [], + proposedPlans: [], + activities: input.activities ?? [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("snoozed thread decider", (it) => { + it.effect("snoozes a thread to a future wake time", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.snoozed"); + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE); + expect(events[0].payload.snoozedAt).toBe(events[0].payload.updatedAt); + } + }), + ); + + it.effect("rejects a wake time that is not in the future", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-past"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: PAST_WAKE, + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects an unparseable wake time", () => + Effect.gen(function* () { + // IsoDateTime is structurally a string, so garbage can reach the + // decider; a NaN wake time must never persist as snooze state. + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-garbage"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "not-a-date", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing blocked-on-you work", () => + Effect.gen(function* () { + const requestActivity = { + id: EventId.make("activity-req-1"), + tone: "approval" as const, + kind: "approval.requested", + summary: "approval.requested", + payload: { requestId: "req-1" }, + turnId: null, + createdAt: NOW, + } as OrchestrationThread["activities"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-blocked"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ activities: [requestActivity] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("re-emits idempotently for a duplicate snooze to the same wake time", () => + Effect.gen(function* () { + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-again"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(events).toHaveLength(1); + if (events[0]?.type === "thread.snoozed") { + // Original snoozedAt preserved; updatedAt must not churn. + expect(events[0].payload.snoozedAt).toBe(SNOOZED_AT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("re-snoozing to a DIFFERENT wake time stamps fresh", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-extend"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "1970-01-03T09:00:00.000Z", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe("1970-01-03T09:00:00.000Z"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("unsnoozes with reason user and re-emits idempotently when awake", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unsnoozed"); + if (events[0]?.type === "thread.unsnoozed") { + expect(events[0].payload.reason).toBe("user"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + + const awake = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze-awake"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({}), + }); + const awakeEvents = Array.isArray(awake) ? awake : [awake]; + expect(awakeEvents[0]?.type).toBe("thread.unsnoozed"); + if (awakeEvents[0]?.type === "thread.unsnoozed") { + // No state change — keep the existing updatedAt. + expect(awakeEvents[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects snoozing a thread with a queued turn start", () => + Effect.gen(function* () { + // The decider clock is the Effect test clock pinned to the epoch: a + // user message 30s before it with no adopting turn is queued work. + const queuedMessage = { + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "1969-12-31T23:59:30.000Z", + updatedAt: "1969-12-31T23:59:30.000Z", + } as OrchestrationThread["messages"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-queued"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ messages: [queuedMessage] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing an archived thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-archived"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ archivedAt: NOW }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("a user message spends the snooze return ticket (activity wake)", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(result) ? result : [result]; + const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed"); + expect(unsnoozed).toBeDefined(); + if (unsnoozed?.type === "thread.unsnoozed") { + expect(unsnoozed.payload.reason).toBe("activity"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ec79ab7a664..7e441a55e2b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -24,6 +24,124 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +// Session adoption takes seconds; a user message still unadopted after this +// window is a failed/stale start, not pending work. Mirrors the client's +// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * Blocked-on-you work derived from the thread's retained activities: an + * approval or user-input request with no later resolution for the same + * requestId. The server-side twin of the shell's hasPendingApprovals / + * hasPendingUserInput flags, which the decider read model does not carry. + * The clearing rules MUST match ProjectionPipeline's pending accounting — + * resolved activities always clear, respond.failed clears only when the + * failure detail marks the request stale/unknown — or settle would be + * rejected on threads whose shell flags read as clear. + */ +function isStaleRequestFailureDetail(payload: Record | null): boolean { + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (detail === null) return false; + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") || + detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request") || + detail.includes("unknown pending user input request") || + detail.includes("unknown pending codex user input request") + ); +} + +// Scans the read model's activities, which the projector caps at the most +// recent 500. That bound is safe here: an OPEN approval/user-input request +// blocks its turn, so the thread cannot accumulate hundreds of later +// activities while one is outstanding — a request that has scrolled out of +// the window is one whose turn kept running, i.e. it was resolved or went +// stale. (The projection pipeline's pendingApprovalCount reads the same +// capped stream and stays consistent with this view.) +function hasOpenBlockingRequest(thread: { + readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; +}): boolean { + const openRequestIds = new Set(); + for (const activity of thread.activities) { + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; + if (requestId === null) continue; + if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + } else if ( + (activity.kind === "provider.approval.respond.failed" || + activity.kind === "provider.user-input.respond.failed") && + isStaleRequestFailureDetail(payload) + ) { + openRequestIds.delete(requestId); + } + } + return openRequestIds.size > 0; +} + +/** + * A queued turn start — a user message no turn has picked up yet — is work + * in flight even though session is still null (turn.start emits + * message-sent + turn-start-requested; the session arrives later). Detection + * mirrors the client's hasQueuedTurnStart: the newest user message is + * strictly newer than every latestTurn timestamp (adoption stamps the new + * turn's requestedAt with the message time, clearing this), and only within + * the adoption grace window — historical threads whose last user message + * postdates their turn timestamps (older-server data, mid-turn messages) + * must not be blocked forever. A failed session start (status "error") + * clears the block immediately. + * + * The age check is bounded on BOTH sides: message timestamps are + * client-supplied, so a client clock ahead of the server yields a negative + * age. Without the lower bound that negative age satisfies `<= grace` for + * as long as the skew lasts, extending the block far past the intended two + * minutes. + */ +function threadHasQueuedTurnStart( + thread: { + readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; + readonly latestTurn: { + readonly requestedAt: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + } | null; + readonly session: { readonly status: string } | null; + }, + occurredAt: string, +): boolean { + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; + return ( + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs && + Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + ); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -327,6 +445,191 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.settle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Server-side twin of the client's canSettle session check: a stale + // or raced client must not settle a thread whose session is coming + // alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has an active session and cannot be settled`, + }), + ); + } + // Pending approval / user-input requests are blocked-on-you work: a + // raced or stale client must not park them behind a settled override + // that would surface only after the request resolves. + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, + }), + ); + } + const occurredAt = yield* nowIso; + // Settling inside the adoption window would hide just-requested work. + if (threadHasQueuedTurnStart(thread, occurredAt)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, + }), + ); + } + // Settling an already-settled thread re-emits with the original + // settledAt: the engine rejects zero-event commands, and bulk-settle / + // double-click must stay silent no-ops rather than surface errors. + const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt: alreadySettled ? thread.settledAt : occurredAt, + // A re-emission is a projected no-op: keep the existing updatedAt + // so duplicate settles neither rewind nor churn ordering. A fresh + // settle stamps the command time. + updatedAt: alreadySettled ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsettle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): reducing the event a + // second time lands on the same override state. A re-emission keeps + // the existing updatedAt so duplicates do not churn ordering. + const alreadyPinnedActive = thread.settledOverride === "active"; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.snooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // A wake time in the past would create a thread that is snoozed and + // woken at once — the row would never leave the inbox but still carry + // snooze state. Reject instead of silently normalizing. The negated + // comparison also catches unparseable wake times (IsoDateTime is + // structurally just a string): NaN fails every comparison, and an + // unparseable snoozedUntil must never persist. + if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, + }), + ); + } + // Blocked-on-you work must not be snoozed away: a pending approval or + // user-input request is the agent waiting on the user, and hiding it + // defeats the request. (A running session IS snoozable — snooze only + // affects visibility, never the agent.) + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, + }), + ); + } + // A queued turn start — a user message no turn has adopted yet — is + // invisible pending work: no session, no pending flags. Snoozing in + // that window would hide a just-requested turn exactly the way settle + // would. + if (threadHasQueuedTurnStart(thread, occurredAt)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, + }), + ); + } + // Re-snoozing an already-snoozed thread to the SAME wake time is a + // duplicate (double-click, raced clients): re-emit with the original + // timestamps so the projection is a no-op. A different wake time is a + // real change and stamps fresh. + const existingSnoozedAt = + thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null + ? thread.snoozedAt + : null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.snoozed", + payload: { + threadId: command.threadId, + snoozedUntil: command.snoozedUntil, + snoozedAt: existingSnoozedAt ?? occurredAt, + updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsnooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): waking a thread that + // is not snoozed lands on the same null state without churning + // updatedAt. + const alreadyAwake = thread.snoozedUntil == null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyAwake ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -504,7 +807,45 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - return [userMessageEvent, turnStartRequestedEvent]; + // Real activity resets ANY override: it wakes an explicitly settled + // thread, and it clears a keep-active pin back to neutral so the + // thread can auto-settle again after this burst of work goes stale. + // A snooze clears the same way — sending a message to a snoozed + // thread is the user re-engaging, so the return ticket is spent. + const lifecycleResetEvents: Array> = []; + if (targetThread.settledOverride !== null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + if (targetThread.snoozedUntil != null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -625,12 +966,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -644,6 +985,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; + // Only a session coming alive is activity worth waking a settled thread + // for — status writes like ready/stopped/error arrive after the fact and + // must not fight a user's explicit settle. Snooze is deliberately NOT + // cleared here: snooze never pauses the agent, so its session starting + // or erroring is not the user re-engaging. Blocked/failed work still + // surfaces immediately — effectiveSnoozed refuses to classify a thread + // with a raised hand (approval / input / failure / fresh completion) + // as snoozed, without spending the return ticket. + const isSessionActivity = + command.session.status === "starting" || command.session.status === "running"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !isSessionActivity) { + return sessionSetEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -798,7 +1168,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, @@ -811,7 +1181,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - return { + const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -825,6 +1195,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; + // An approval or user-input request is blocked-on-you work — it must + // never stay hidden inside a settled slim row. + const wakesSettledThread = + command.activity.kind === "approval.requested" || + command.activity.kind === "user-input.requested"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !wakesSettledThread) { + return activityAppendedEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 016c3d508ec..659665e47b5 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; import { normalizeDispatchCommand } from "./Normalizer.ts"; import { annotateEnvironmentRequest, @@ -69,7 +70,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( if (Option.isNone(snapshot)) { return yield* failEnvironmentNotFound("thread_not_found"); } - return snapshot.value; + return projectThreadDetailSnapshot(snapshot.value); }), ) .handle( diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts new file mode 100644 index 00000000000..2070c44418a --- /dev/null +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -0,0 +1,88 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects settled lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.settledAt).toBe(now); + + const userUnsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + }), + ); + expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); + expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + + const activityUnsettled = yield* projectEvent( + userUnsettled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + }), + ); + expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); + expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..9c07a312023 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,6 +89,10 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..0504cb36f9a 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,7 +22,11 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSettledPayload, + ThreadSnoozedPayload, ThreadUnarchivedPayload, + ThreadUnsettledPayload, + ThreadUnsnoozedPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -286,6 +290,10 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], activities: [], @@ -337,6 +345,54 @@ export function projectEvent( })), ); + case "thread.settled": + return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: "settled", + settledAt: payload.settledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsettled": + return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.snoozed": + return decodeForEvent(ThreadSnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: payload.snoozedUntil, + snoozedAt: payload.snoozedAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsnoozed": + return decodeForEvent(ThreadUnsnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: null, + snoozedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index aaa460470f4..c5856cb9c48 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,6 +91,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -130,4 +134,69 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); + + it.effect("round-trips non-null settlement values through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-settled"), + projectId: ProjectId.make("project-1"), + title: "Settled thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + requestingRestart: 0, + restartRequestReason: null, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + archivedAt: null, + settledOverride: "settled", + settledAt: "2026-03-25T00:00:00.000Z", + snoozedUntil: "2026-03-26T09:00:00.000Z", + snoozedAt: "2026-03-25T00:00:00.000Z", + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const row = Option.getOrNull(persisted); + if (!row) { + return yield* Effect.die("Expected settled projection_threads row to exist."); + } + assert.strictEqual(row.settledOverride, "settled"); + assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); + assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); + + // Un-settle to the keep-active pin and wake the snooze; confirm the + // flips persist. + yield* threads.upsert({ + ...row, + settledOverride: "active", + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + }); + const repersisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const updated = Option.getOrNull(repersisted); + assert.strictEqual(updated?.settledOverride, "active"); + assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.snoozedUntil, null); + assert.strictEqual(updated?.snoozedAt, null); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index a46d50a96c5..acf6e348238 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,6 +43,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + settled_override, + settled_at, + snoozed_until, + snoozed_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -64,6 +68,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.settledOverride}, + ${row.settledAt}, + ${row.snoozedUntil}, + ${row.snoozedAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -85,6 +93,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + settled_override = excluded.settled_override, + settled_at = excluded.settled_at, + snoozed_until = excluded.snoozed_until, + snoozed_at = excluded.snoozed_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -113,6 +125,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -143,6 +159,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 3f55764307d..3bd285e5c29 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -46,6 +46,12 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadRestartRequest.ts"; +// Upstream shipped these as 033/034; renumbered to 034/035 because this fork's +// 033 is already recorded in production's effect_sql_migrations. The Migrator +// skips any id <= the recorded max WITHOUT comparing names, so reusing 033 here +// would silently never create the settled columns. See UPSTREAM_DIVERGENCE.md. +import Migration0034 from "./Migrations/034_ProjectionThreadsSettled.ts"; +import Migration0035 from "./Migrations/035_ProjectionThreadsSnoozed.ts"; /** * Migration loader with all migrations defined inline. @@ -91,6 +97,8 @@ export const migrationEntries = [ [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadRestartRequest", Migration0033], + [34, "ProjectionThreadsSettled", Migration0034], + [35, "ProjectionThreadsSnoozed", Migration0035], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/034_035_ForkMigrationOffset.test.ts b/apps/server/src/persistence/Migrations/034_035_ForkMigrationOffset.test.ts new file mode 100644 index 00000000000..6d70f19b3a4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_035_ForkMigrationOffset.test.ts @@ -0,0 +1,77 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +/** + * Guards the fork's migration-id offset against the production database. + * + * Upstream shipped its settled/snoozed migrations as 033/034. This fork already + * owns id 33 (`ProjectionThreadRestartRequest`) and it has been recorded in + * production's `effect_sql_migrations` since 2026-07-09, so upstream's pair is + * renumbered locally to 034/035. + * + * This matters because Effect's Migrator run loop is purely ordinal — + * `if (currentId <= latestMigrationId) continue` — with no name or checksum + * comparison. Had the fork's migration been renumbered upward instead, an + * upstream migration reusing id 33 would be silently skipped on any database + * that already recorded 33, the settled columns would never be created, and + * every thread read and write would fail with `no such column`. + * + * A fresh-database test cannot catch that: it runs every migration from zero. + * The regression only reproduces when the ledger already carries a high-water + * mark, which is exactly the state of the production database. + */ +layer("034_035 fork migration offset", (it) => { + it.effect("applies the settled and snoozed migrations over a ledger already at 33", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Reproduce production: the fork's own migration is the recorded maximum. + yield* runMigrations({ toMigrationInclusive: 33 }); + + const beforeMax = yield* sql<{ readonly max_id: number }>` + SELECT MAX(migration_id) AS max_id FROM effect_sql_migrations + `; + assert.strictEqual(beforeMax[0]?.max_id, 33); + + yield* runMigrations(); + + const tail = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id IN (33, 34, 35) + ORDER BY migration_id + `; + assert.deepStrictEqual(tail, [ + { migration_id: 33, name: "ProjectionThreadRestartRequest" }, + { migration_id: 34, name: "ProjectionThreadsSettled" }, + { migration_id: 35, name: "ProjectionThreadsSnoozed" }, + ]); + + // The columns the auto-merged projection queries reference unconditionally. + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const names = new Set(columns.map((column) => column.name)); + for (const required of [ + "requesting_restart", + "restart_request_reason", + "settled_override", + "settled_at", + "snoozed_until", + "snoozed_at", + ]) { + assert.isTrue(names.has(required), `projection_threads is missing ${required}`); + } + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/034_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSettled.ts new file mode 100644 index 00000000000..e93407defe2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSettled.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "settled_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_override TEXT + `; + } + + if (!columns.some((column) => column.name === "settled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadsSnoozed.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadsSnoozed.ts new file mode 100644 index 00000000000..5259ae44501 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadsSnoozed.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "snoozed_until")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_until TEXT + `; + } + + if (!columns.some((column) => column.name === "snoozed_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c91ded8fc5e..8bc853daae7 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,6 +36,10 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), + settledAt: Schema.NullOr(IsoDateTime), + snoozedUntil: Schema.NullOr(IsoDateTime), + snoozedAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 693111b578f..8b3dabfa338 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -67,6 +67,32 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("orders list snapshots and events with one monotonic revision", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + const before = yield* manager.list({ threadId }); + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/ready", + }); + + const events = yield* collector.drain; + const listed = yield* manager.list({ threadId }); + expect(events).toHaveLength(2); + expect(events[0]!.serverEpoch).toBe(listed.serverEpoch); + expect(events[1]!.serverEpoch).toBe(listed.serverEpoch); + expect(events[0]!.revision).toBeGreaterThan(before.revision); + expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); + expect(listed.revision).toBe(events[1]!.revision); + expect(listed.sessions).toHaveLength(1); + }), + ); + it.effect("treats bare hosts as https", () => Effect.gen(function* () { const threadId = freshThreadId(); @@ -254,6 +280,26 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("gives every tab in a batch close its own monotonic revision", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.open({ threadId, url: "http://localhost:3000" }); + const collector = yield* collectEvents; + + yield* manager.close({ threadId }); + + const events = yield* collector.drain; + const listed = yield* manager.list({ threadId }); + expect(events).toHaveLength(2); + expect(events.every((event) => event.type === "closed")).toBe(true); + expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); + expect(listed.revision).toBe(events[1]!.revision); + expect(listed.sessions).toHaveLength(0); + }), + ); + it.effect("close is idempotent for unknown threads", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 193ea85b21b..e38e28ecd2e 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -30,6 +30,7 @@ import { newPreviewTabId, normalizePreviewUrl, } from "@t3tools/shared/preview"; +import * as NodeCrypto from "node:crypto"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -67,9 +68,17 @@ interface PreviewSessionState { interface ManagerState { /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ readonly sessions: ReadonlyMap; + /** Global monotonic revision establishing list/event ordering. */ + readonly revision: number; } -const initialState: ManagerState = { sessions: new Map() }; +const initialState: ManagerState = { sessions: new Map(), revision: 0 }; + +type PreviewEventDraft = PreviewEvent extends infer Event + ? Event extends { readonly revision: number } + ? Omit + : never + : never; const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; @@ -138,6 +147,7 @@ const buildIdleSnapshot = (input: { }); export const make = Effect.gen(function* PreviewManagerMake() { + const serverEpoch = NodeCrypto.randomUUID(); const stateRef = yield* SynchronizedRef.make(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -160,7 +170,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { tabId: string, mutator: ( session: PreviewSessionState, - ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, + ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>, ): Effect.Effect => { type ModifyResult = | { kind: "fail"; error: PreviewSessionLookupError } @@ -177,10 +187,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { return mutator(session).pipe( Effect.flatMap( Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { - if (emit) yield* PubSub.publish(eventsPubSub, emit); + const revision = emit ? state.revision + 1 : state.revision; + if (emit) { + yield* PubSub.publish(eventsPubSub, { + ...emit, + revision, + serverEpoch, + } as PreviewEvent); + } const sessions = new Map(state.sessions); sessions.set(compositeKey(threadId, tabId), next); - return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ + return [{ kind: "ok", result } as ModifyResult, { sessions, revision }] as readonly [ ModifyResult, ManagerState, ]; @@ -207,22 +224,27 @@ export const make = Effect.gen(function* PreviewManagerMake() { updatedAt, }) : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); - yield* SynchronizedRef.update(stateRef, (state) => { - const sessions = new Map(state.sessions); - sessions.set(compositeKey(input.threadId, tabId), { - threadId: input.threadId, - tabId, - snapshot, - }); - return { sessions }; - }); - yield* PubSub.publish(eventsPubSub, { - type: "opened", - threadId: input.threadId, - tabId, - createdAt: snapshot.updatedAt, - snapshot, - }); + yield* SynchronizedRef.modifyEffect(stateRef, (state) => + Effect.gen(function* () { + const revision = state.revision + 1; + const sessions = new Map(state.sessions); + sessions.set(compositeKey(input.threadId, tabId), { + threadId: input.threadId, + tabId, + snapshot, + }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", + threadId: input.threadId, + tabId, + createdAt: snapshot.updatedAt, + serverEpoch, + revision, + snapshot, + }); + return [snapshot, { sessions, revision }] as const; + }), + ); return snapshot; }, ); @@ -280,7 +302,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, updatedAt, }; - const emit: PreviewEvent = + const emit: PreviewEventDraft = input.navStatus._tag === "LoadFailed" ? { type: "failed", @@ -349,7 +371,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( function* (input) { const createdAt = yield* currentIsoTimestamp; - const events = yield* SynchronizedRef.modify(stateRef, (state) => { + yield* SynchronizedRef.modifyEffect(stateRef, (state) => { const eventsToEmit: PreviewEvent[] = []; const sessions = new Map(state.sessions); const targets = input.tabId @@ -357,25 +379,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { (entry): entry is PreviewSessionState => entry !== undefined, ) : sessionsForThread(state, input.threadId); + let revision = state.revision; for (const target of targets) { + revision += 1; sessions.delete(compositeKey(target.threadId, target.tabId)); eventsToEmit.push({ type: "closed", threadId: target.threadId, tabId: target.tabId, createdAt, + serverEpoch, + revision, }); } if (eventsToEmit.length === 0) { - return [eventsToEmit, state] as const; + return Effect.succeed([undefined, state] as const); } - return [eventsToEmit, { sessions }] as const; + return Effect.as( + Effect.forEach(eventsToEmit, (event) => PubSub.publish(eventsPubSub, event), { + discard: true, + }), + [undefined, { sessions, revision }] as const, + ); }); - if (events.length > 0) { - yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { - discard: true, - }); - } }, ); @@ -387,6 +413,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { sessions: sessionsForThread(state, input.threadId) .map((s) => s.snapshot) .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + serverEpoch, + revision: state.revision, }), ), ); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 0b017b22e4e..75db78844a5 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge( + ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), + ), + ), Layer.provideMerge(NodeServices.layer), ); @@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => ProjectFaviconResolver.make.pipe( - Effect.provide(WorkspacePaths.layer), + Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]), Effect.provideService(FileSystem.FileSystem, fileSystem), ); @@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("prefers a t3.json iconPath over well-known files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }'); + yield* writeTextFile(cwd, "brand/mark.svg", "mark"); + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand/mark.svg"); + }), + ); + + it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }'); + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + + it.effect("ignores invalid t3.json files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "t3.json", "{ not json"); + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + + it.effect("does not resolve a t3.json iconPath outside the workspace root", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const parent = yield* makeTempDir; + const cwd = `${parent}/app`; + yield* writeTextFile(parent, "secret.svg", "secret"); + yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }'); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + it.effect("resolves icon hrefs from project source files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index e644df06ae6..2c7195de630 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -117,6 +118,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader; const resolveIconHref = (href: string): ReadonlyArray => { const clean = href.replace(/^\//, ""); @@ -177,6 +179,15 @@ export const make = Effect.gen(function* () { }), ), ); + // A t3.json iconPath takes precedence over the well-known locations. + const projectFile = yield* projectFileLoader.load(projectCwd); + if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { + const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); + if (existing) { + return existing; + } + } + for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { diff --git a/apps/server/src/project/T3ProjectFileLoader.test.ts b/apps/server/src/project/T3ProjectFileLoader.test.ts new file mode 100644 index 00000000000..c4761825387 --- /dev/null +++ b/apps/server/src/project/T3ProjectFileLoader.test.ts @@ -0,0 +1,89 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(T3ProjectFileLoader.layer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-project-file-", + }); +}); + +const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, contents: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, "t3.json"), contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer)("T3ProjectFileLoader", (it) => { + describe("load", () => { + it.effect("loads and decodes a valid t3.json", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + yield* writeProjectFile( + cwd, + `{ + // JSONC is tolerated + "iconPath": "assets/logo.svg", + "scripts": [{ "name": "Dev", "command": "pnpm dev" }], + }`, + ); + + const loaded = yield* loader.load(cwd); + + expect(Option.isSome(loaded)).toBe(true); + if (Option.isSome(loaded)) { + expect(loaded.value.iconPath).toBe("assets/logo.svg"); + expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]); + } + }), + ); + + it.effect("returns none when t3.json is missing", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + + const loaded = yield* loader.load(cwd); + + expect(Option.isNone(loaded)).toBe(true); + }), + ); + + it.effect("returns none for malformed JSON without failing", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + yield* writeProjectFile(cwd, "{ not json"); + + const loaded = yield* loader.load(cwd); + + expect(Option.isNone(loaded)).toBe(true); + }), + ); + + it.effect("returns none for schema-invalid files without failing", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }'); + + const loaded = yield* loader.load(cwd); + + expect(Option.isNone(loaded)).toBe(true); + }), + ); + }); +}); diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts new file mode 100644 index 00000000000..00ad9d3835a --- /dev/null +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -0,0 +1,108 @@ +/** + * T3ProjectFileLoader - Effect service that loads the checked-in `t3.json` + * project file from a workspace root. + * + * Loading is best-effort: a missing file resolves to `Option.none`, and + * unreadable or invalid files are logged and treated as absent so callers + * can fall back to their defaults. + * + * @module T3ProjectFileLoader + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts"; +import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile"; + +const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson); + +export class T3ProjectFileLoadError extends Schema.TaggedErrorClass()( + "T3ProjectFileLoadError", + { + operation: Schema.Literals(["read", "decode"]), + workspaceRoot: Schema.String, + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`; + } +} + +/** Service tag for t3.json project file loading. */ +export class T3ProjectFileLoader extends Context.Service< + T3ProjectFileLoader, + { + /** + * Load and decode `t3.json` at the workspace root. + * + * Never fails: missing, unreadable, or invalid files resolve to + * `Option.none` (invalid files are logged as warnings). + */ + readonly load: (workspaceRoot: string) => Effect.Effect>; + } +>()("t3/project/T3ProjectFileLoader") {} + +const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + workspaceRoot: error.workspaceRoot, + filePath: error.filePath, + errorTag: error._tag, + }), + ); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")( + function* (workspaceRoot) { + const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME); + const raw = yield* fileSystem.readFileString(filePath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : logT3ProjectFileLoadError( + new T3ProjectFileLoadError({ + operation: "read", + workspaceRoot, + filePath, + cause: error, + }), + ).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isNone(raw)) { + return Option.none(); + } + return yield* decodeT3ProjectFileJson(raw.value).pipe( + Effect.map(Option.some), + Effect.catchTags({ + SchemaError: (error) => + logT3ProjectFileLoadError( + new T3ProjectFileLoadError({ + operation: "decode", + workspaceRoot, + filePath, + cause: error, + }), + ).pipe(Effect.as(Option.none())), + }), + ); + }, + ); + + return T3ProjectFileLoader.of({ load }); +}); + +export const layer = Layer.effect(T3ProjectFileLoader, make); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ee9cddf949c..c2fc11311aa 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -118,6 +118,7 @@ export const ClaudeDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; @@ -165,9 +166,11 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, + cwd, ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts new file mode 100644 index 00000000000..1ad843d7573 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -0,0 +1,205 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + contents: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); +}); + +it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("discovers user and project skills with frontmatter metadata", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "codex-review", + [ + "---", + "name: codex-review", + "description: Ask Codex for a review.", + "---", + "", + "# Body", + ].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "codex-review", + path: path.join(configDir, "skills", "codex-review", "SKILL.md"), + enabled: true, + scope: "user", + description: "Ask Codex for a review.", + }, + { + name: "deploy", + path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Deploy the app.", + }, + ]); + }), + ); + + it.effect("prefers project skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.equal(skills.length, 1); + assert.equal(skills[0]?.scope, "project"); + assert.equal(skills[0]?.description, "Project deploy."); + }), + ); + + it.effect("falls back to the directory name and skips malformed frontmatter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const skillsDir = path.join(configDir, "skills"); + + yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n"); + yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n"); + // A stray file (not a directory with SKILL.md) must be skipped. + yield* fs.makeDirectory(skillsDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + // A skill with no frontmatter falls back to its directory name; a skill + // whose frontmatter fails to parse is skipped entirely (Claude Code + // won't load it either). + assert.deepEqual( + skills.map((skill) => skill.name), + ["no-frontmatter"], + ); + assert.equal(skills[0]?.description, undefined); + }), + ); + + it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const environmentConfigDir = path.join(tempDir, "env-config"); + + yield* writeSkill( + path.join(environmentConfigDir, "skills"), + "env-skill", + ["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["env-skill"], + ); + + // An explicit homePath wins over the environment variable, matching + // makeClaudeEnvironment which overwrites CLAUDE_CONFIG_DIR for the CLI. + const explicitHome = path.join(tempDir, "explicit-home"); + yield* writeSkill( + path.join(explicitHome, "skills"), + "explicit-skill", + ["---", "name: explicit-skill", "---"].join("\n"), + ); + const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }); + assert.deepEqual( + explicitSkills.map((skill) => skill.name), + ["explicit-skill"], + ); + }), + ); + + it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + + // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own + // cwd (the workspace), so discovery must do the same. + yield* writeSkill( + path.join(workspace, "relative-config", "skills"), + "relative-skill", + ["---", "name: relative-skill", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: "" }, workspace, { + CLAUDE_CONFIG_DIR: "relative-config", + }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["relative-skill"], + ); + assert.equal(skills[0]?.scope, "user"); + }), + ); + + it.effect("returns an empty list when no skill roots exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + + const skills = yield* discoverClaudeSkills( + { homePath: path.join(tempDir, "missing-home") }, + path.join(tempDir, "missing-workspace"), + ); + + assert.deepEqual(skills, []); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts new file mode 100644 index 00000000000..335c3d4681d --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -0,0 +1,148 @@ +/** + * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. + * + * Claude Code loads skills from `/skills` (user scope) and + * `/.claude/skills` (project scope), one directory per skill with a + * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces + * skills only as slash commands without their filesystem paths, so the + * provider snapshot scans the same locations directly, mirroring how the + * Codex app-server reports its skills. + * + * @module provider/Drivers/ClaudeSkills + */ +import * as NodeOS from "node:os"; + +import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { parse as parseYamlDocument } from "yaml"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +type ClaudeSkillScope = "user" | "project"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +type SkillFrontmatter = + | { readonly kind: "missing" } + | { readonly kind: "malformed" } + | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + +function parseSkillFrontmatter(contents: string): SkillFrontmatter { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return { kind: "missing" }; + } + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return { kind: "malformed" }; + } + if (typeof parsed !== "object" || parsed === null) { + return { kind: "malformed" }; + } + + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + kind: "parsed", + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + +/** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` + * already present in the process environment, then `~/.claude`. + */ +const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( + config: Pick, + environment: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + if (homePath.length > 0) { + return path.resolve(expandHomePath(homePath)); + } + // No tilde expansion here: the spawned CLI receives this env var verbatim + // (env vars are never shell-expanded), so a literal `~` must stay literal + // for discovery to scan the same directory the runtime would. A relative + // value is resolved against the workspace cwd — the subprocess's own cwd — + // for the same reason. + const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (environmentConfigDir.length > 0) { + return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); + } + return path.join(NodeOS.homedir(), ".claude"); +}); + +/** + * Enumerate Claude Code skills from the user config dir and the workspace. + * Discovery is best-effort: unreadable roots and malformed skill entries are + * skipped so a broken skill never degrades the provider snapshot. On name + * collisions the project-scoped skill wins, matching Claude Code's + * most-specific-wins resolution. + */ +export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( + config: Pick, + cwd?: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); + + const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ + { directory: path.join(configDirPath, "skills"), scope: "user" }, + ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ]; + + const skillsByName = new Map(); + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of [...entries].sort()) { + const skillPath = path.join(root.directory, entry, "SKILL.md"); + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const frontmatter = parseSkillFrontmatter(contents); + // Malformed frontmatter means the skill won't load in Claude Code + // either — skip it rather than surfacing a broken entry under its + // directory name. + if (frontmatter.kind === "malformed") { + continue; + } + + const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + if (!name) { + continue; + } + + skillsByName.set(name, { + name, + path: skillPath, + enabled: true, + scope: root.scope, + ...(frontmatter.kind === "parsed" && frontmatter.description + ? { description: frontmatter.description } + : {}), + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 99b5dfaffd0..ce3ed7f0f7a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -356,6 +356,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("derives auto permission mode from auto runtime policy without skip flag", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -511,6 +530,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Opus 5", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-5", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 83665ae7a3c..bd746a500c9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -346,7 +346,6 @@ function selectedClaudeContextWindow( modelSelection: ModelSelection | undefined, ): number | undefined { switch (modelSelection?.model) { - case "claude-opus-5": case "claude-opus-4-8": case "claude-opus-4-7": // Always 1M at the API; these models expose no contextWindow option. @@ -3545,6 +3544,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", + auto: "auto", "full-access": "bypassPermissions", }; const permissionMode = runtimeModeToPermission[input.runtimeMode]; diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts new file mode 100644 index 00000000000..ab6e5992990 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -0,0 +1,138 @@ +import { ClaudeSettings } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildClaudeCapabilitiesProbeQueryOptions, + CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + probeClaudeCapabilities, +} from "./ClaudeProvider.ts"; + +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +it("isolates Claude capability probes without dropping workspace setting sources", () => { + const abortController = new AbortController(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath: "/usr/bin/claude", + abortController, + environment: { + HOME: "/home/user", + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + cwd: "/workspace/project", + }); + + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.strictMcpConfig, true); + assert.equal(options.cwd, "/workspace/project"); + assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); + assert.deepEqual(options.allowedTools, []); + assert.equal(options.persistSession, false); + assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); + assert.equal(options.abortController, abortController); + assert.equal(options.env?.HOME, "/home/user"); + assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); +}); + +it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { + it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const invocationPath = path.join(tempDir, "invocation.json"); + const workspaceCwd = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspaceCwd, { recursive: true }); + + yield* fs.writeFileString( + executablePath, + [ + "#!/usr/bin/env node", + 'import { existsSync, readFileSync, writeFileSync } from "node:fs";', + 'import { createInterface } from "node:readline";', + "const args = process.argv.slice(2);", + 'const mcpConfigIndex = args.indexOf("--mcp-config");', + "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;", + "let mcpConfig;", + "if (rawMcpConfig) {", + ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;', + " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }", + "}", + "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", + " args,", + " cwd: process.cwd(),", + " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", + " mcpConfig,", + "}));", + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fs.chmod(executablePath, 0o755); + + const capabilities = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { + ...process.env, + T3_PROBE_INVOCATION_PATH: invocationPath, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + workspaceCwd, + ); + + assert.deepEqual(capabilities, { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + apiProvider: undefined, + slashCommands: [ + { + name: "review", + description: "Review changes", + input: { hint: "[path]" }, + }, + ], + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly connectorEnv: string; + readonly mcpConfig: unknown; + }; + assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); + assert.equal(invocation.connectorEnv, "false"); + assert.equal(invocation.args.includes("--strict-mcp-config"), true); + assert.equal(invocation.args.includes("--mcp-config"), false); + assert.equal(invocation.mcpConfig, undefined); + + assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index a8be7cbbb6a..96202ecd952 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; @@ -21,8 +22,10 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, + type SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -38,6 +41,7 @@ import { } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -47,6 +51,7 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; +const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; @@ -107,6 +112,15 @@ const BUILT_IN_MODELS: ReadonlyArray = [ id: "fastMode", label: "Fast Mode", }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), ], }), }, @@ -295,6 +309,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +function supportsClaudeOpus5(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; +} + function supportsClaudeFable5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; } @@ -311,9 +329,10 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { - // Opus 5 shipped in the same Claude Code generation as Fable 5, so both - // share the Fable 5 minimum-version gate. - if (model.slug === "claude-fable-5" || model.slug === "claude-opus-5") { + if (model.slug === "claude-opus-5") { + return supportsClaudeOpus5(version); + } + if (model.slug === "claude-fable-5") { return supportsClaudeFable5(version); } if (model.slug === "claude-opus-4-8") { @@ -326,9 +345,14 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeOpus5UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; +} + function formatClaudeFable5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Fable 5 and Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access them.`; + return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; } function formatClaudeOpus48UpgradeMessage(version: string | null): string { @@ -538,6 +562,44 @@ function apiProviderAuthMetadata( // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +/** + * Keep workspace-scoped command discovery intact while isolating the periodic + * health check from configured MCP servers. + */ +export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** Build the exact SDK options used by the periodic Claude capability probe. */ +export function buildClaudeCapabilitiesProbeQueryOptions(input: { + readonly executablePath: string; + readonly abortController: AbortController; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; +}): ClaudeQueryOptions { + return { + persistSession: false, + pathToClaudeCodeExecutable: input.executablePath, + abortController: input.abortController, + settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], + allowedTools: [], + // Ignore MCP definitions from every filesystem setting source above. The + // SDK combines this empty explicit map with --strict-mcp-config. + mcpServers: {}, + strictMcpConfig: true, + env: { + ...input.environment, + // Connected claude.ai MCP servers are discovered outside filesystem + // config; disable them independently for this health check. + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + ...(input.cwd ? { cwd: input.cwd } : {}), + stderr: () => {}, + }; +} + function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); return candidate ? candidate : undefined; @@ -661,16 +723,12 @@ const probeClaudeCapabilities = ( prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), - options: { - persistSession: false, - pathToClaudeCodeExecutable: executablePath, + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, abortController: abort, - settingSources: ["user", "project", "local"], - allowedTools: [], - env: claudeEnvironment, - ...(cwd ? { cwd } : {}), - stderr: () => {}, - }, + environment: claudeEnvironment, + cwd, + }), }); const init = await q.initializationResult(); const account = init.account as @@ -726,10 +784,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings: ClaudeSettings, ) => Effect.Effect, environment?: NodeJS.ProcessEnv, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Path.Path + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -828,17 +887,20 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeFable5(parsedVersion) + const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) ? undefined - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; + const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); @@ -849,6 +911,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, @@ -870,6 +933,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + skills, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 119fa36303a..d7346a0e0db 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -104,6 +104,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "never", + approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess", }, @@ -149,6 +150,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "on-request", + approvalsReviewer: "user", sandboxPolicy: { type: "workspaceWrite", }, @@ -193,6 +195,31 @@ describe("buildTurnStartParams", () => { NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); }); + it.effect("routes approvals to the auto reviewer in auto mode", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "auto", + prompt: "Ship it", + }); + + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandboxPolicy: { + type: "workspaceWrite", + }, + input: [ + { + type: "text", + text: "Ship it", + }, + ], + }); + }), + ); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -205,6 +232,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "untrusted", + approvalsReviewer: "user", sandboxPolicy: { type: "readOnly", }, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5a81e915e34..67108dd4dbb 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -264,23 +264,35 @@ function readResumeCursorThreadId( function runtimeModeToThreadConfig(input: RuntimeMode): { readonly approvalPolicy: EffectCodexSchema.V2ThreadStartParams__AskForApproval; readonly sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode; + // Always explicit: omitting the field on resume keeps the thread's previous + // reviewer, which would leave auto_review sticky after switching modes. + readonly approvalsReviewer: EffectCodexSchema.V2ThreadStartParams__ApprovalsReviewer; } { switch (input) { case "approval-required": return { approvalPolicy: "untrusted", sandbox: "read-only", + approvalsReviewer: "user", }; case "auto-accept-edits": return { approvalPolicy: "on-request", sandbox: "workspace-write", + approvalsReviewer: "user", + }; + case "auto": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + approvalsReviewer: "auto_review", }; case "full-access": default: return { approvalPolicy: "never", sandbox: "danger-full-access", + approvalsReviewer: "user", }; } } @@ -296,6 +308,7 @@ function buildThreadStartParams(input: { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, + approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; @@ -310,6 +323,7 @@ function runtimeModeToTurnSandboxPolicy( type: "readOnly", }; case "auto-accept-edits": + case "auto": return { type: "workspaceWrite", }; @@ -382,6 +396,7 @@ export function buildTurnStartParams(input: { threadId: input.threadId, input: turnInput, approvalPolicy: config.approvalPolicy, + approvalsReviewer: config.approvalsReviewer, sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 05160517bfe..41454b48b31 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -97,10 +97,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), loadInventoryFromCli: () => runtimeMock.state.inventoryError - ? Effect.succeed({ - providerList: { all: [], default: {}, connected: [] as string[] }, - agents: [], - } as OpenCodeInventory) + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; @@ -212,14 +215,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("degrades gracefully on CLI failure for local installs", () => + it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: opencode models failed", + ); }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index a993269c28d..3e52e050ce5 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops stale OpenCode models missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains stale OpenCode models when a refresh fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode provider status has not been checked in this session yet.", + } satisfies ServerProvider; + const loggedOutProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "OpenCode is available, but it did not report any connected upstream providers.", + } satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:03:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const authoritativeProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:04:00.000Z", + models: [previousProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:05:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ + ...previousProvider.models, + ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).models, + [], + ); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); + + const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); + const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); + + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -866,6 +1036,144 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect( + "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", + () => + Effect.gen(function* () { + const openCodeDriver = ProviderDriverKind.make("opencode"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const initialProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const authoritativeProvider = { + ...initialProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [initialProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(authoritativeProvider), + streamChanges: Stream.fromPubSub(changes), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-opencode-authoritative-persist-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: openCodeInstanceId, + }); + + yield* PubSub.publish(changes, authoritativeProvider); + + let cachedProvider = yield* readProviderStatusCache(filePath); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + + yield* PubSub.publish(changes, failedProvider); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + authoritativeProvider.models[0]!, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("returns the cached provider list when a manual refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -1534,30 +1842,70 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect("includes Claude Fable 5 on supported Claude Code versions", () => + it.effect("includes Claude Opus 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), ); - const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); - assert.strictEqual(fable5?.name, "Claude Fable 5"); const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); assert.strictEqual(opus5?.name, "Claude Opus 5"); - const opus5Descriptors = opus5?.capabilities?.optionDescriptors ?? []; - assert.deepStrictEqual( - opus5Descriptors.map((descriptor) => descriptor.id), - ["effort", "fastMode"], + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 5 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-5"), + false, ); - const opus5Effort = opus5Descriptors.find((descriptor) => descriptor.id === "effort"); assert.strictEqual( - opus5Effort?.type === "select" && - (opus5Effort.options ?? []).some((option) => option.id === "xhigh"), - true, + status.message, + "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), ); - // The runtime must honor the advertised xhigh choice rather than - // downgrading it via the older-model compatibility mapping. - assert.strictEqual(normalizeClaudeCliEffort("xhigh", "claude-opus-5"), "xhigh"); + const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); + assert.strictEqual(fable5?.name, "Claude Fable 5"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -1585,13 +1933,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te status.models.some((model) => model.slug === "claude-fable-5"), false, ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-5"), - false, - ); assert.strictEqual( status.message, - "Claude Code v2.1.168 is too old for Claude Fable 5 and Claude Opus 5. Upgrade to v2.1.169 or newer to access them.", + "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.", ); }).pipe( Effect.provide( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..760c8e1c59e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,11 +78,31 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { + if (provider.driver !== ProviderDriverKind.make("opencode")) { + return true; + } + + // OpenCode's initial snapshot is deliberately non-authoritative while its + // first probe is still running. A probe error from an installed CLI/server + // is likewise partial: it could not establish the current inventory. + // Conversely, disabled and missing-CLI snapshots are authoritative removals, + // as are successful ready/warning inventories (including an empty one after + // logout or plugin removal). + const isPendingInitialProbe = + provider.enabled && !provider.installed && provider.status === "warning"; + const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; + return isPendingInitialProbe || didInstalledProviderProbeFail; +}; + const mergeProviderModels = ( + provider: ServerProvider, previousModels: ReadonlyArray, nextModels: ReadonlyArray, ): ReadonlyArray => { - if (nextModels.length === 0 && previousModels.length > 0) { + const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); + + if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) { return previousModels; } @@ -98,7 +118,9 @@ const mergeProviderModels = ( }; }); const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; + return shouldRetainMissingModels + ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))] + : mergedModels; }; export const mergeProviderSnapshot = ( @@ -109,7 +131,7 @@ export const mergeProviderSnapshot = ( ? nextProvider : { ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), + models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), }; export const mergeProviderSnapshots = ( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3683c88bec8..459ef9664ad 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,6 +100,8 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index b853662b037..63fcea22d19 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -690,22 +690,36 @@ const makeOpenCodeRuntime = Effect.gen(function* () { agentsResult = a2; } - // Degrade gracefully on failure — return empty inventory (warning status, not error) - let connected: string[] = []; - let allProviders: ProviderListResponse["all"] = []; - if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - connected = [...parsed.connected]; - allProviders = [...parsed.providers.values()].map((p) => ({ - id: p.id, - name: p.name, + if (modelsResult._tag === "Failure") { + const cause = Cause.squash(modelsResult.cause); + return yield* ensureRuntimeError( + "loadInventoryFromCli", + `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ); + } + if (modelsResult.value.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + }); + } + + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const connected = [...parsed.connected]; + const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( + (provider) => ({ + id: provider.id, + name: provider.name, source: "config" as const, env: [], options: {}, - models: p.models, - })); - } + models: provider.models, + }), + ); + // Agent metadata enriches model capabilities but is not required for an + // authoritative model inventory, so it may still degrade to an empty list. let agents: ReadonlyArray = []; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { agents = parseAgentListCliOutput(agentsResult.value.stdout); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index f1686eecd3d..a8a1c8d591f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -453,6 +455,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", @@ -611,6 +615,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 355100e4c6d..e28882c14c4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -92,6 +92,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -165,6 +166,8 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -194,6 +197,8 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -378,6 +383,7 @@ const buildAppUnderTest = (options?: { ...derivedPaths, staticDir: undefined, devUrl, + devAllowedOrigins: [], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: defaultDesktopBootstrapToken, @@ -503,7 +509,10 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePaths.layer), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), + ), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -675,7 +684,7 @@ const buildAppUnderTest = (options?: { reportStatus: () => Effect.void, refresh: () => Effect.void, close: () => Effect.void, - list: () => Effect.succeed({ sessions: [] }), + list: () => Effect.succeed({ sessions: [], serverEpoch: "test-server", revision: 0 }), events: Stream.empty, subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), @@ -1337,6 +1346,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "bearer-access-token", "dpop-access-token", ]); + // Desktop, so port-scoped: instances scan for a free port and share + // 127.0.0.1, and cookies are not scoped by port. assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3255,6 +3266,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("allows configured development origins through ServerConfig", () => + Effect.gen(function* () { + const tailnetOrigin = "https://host.example.ts.net"; + yield* buildAppUnderTest({ + config: { + devUrl: new URL(crossOriginClientOrigin), + devAllowedOrigins: [tailnetOrigin], + }, + }); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + method: "OPTIONS", + headers: { + origin: tailnetOrigin, + "access-control-request-method": "GET", + "access-control-request-headers": "content-type", + }, + }); + + assert.equal(response.status, 204); + assertBrowserApiCorsPreflightHeaders(response.headers, { + origin: tailnetOrigin, + credentials: true, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) { it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () => Effect.gen(function* () { @@ -5298,6 +5337,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, @@ -5344,6 +5384,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, @@ -5420,6 +5461,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Deferred.succeed(localRefreshStarted, undefined).pipe( Effect.ignore, @@ -5516,6 +5558,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 51a9f4e19ce..4e079692ba1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,6 +55,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -80,6 +81,7 @@ import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as HostStats from "./diagnostics/HostStats.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; @@ -267,6 +269,7 @@ const WorkspaceLayerLive = Layer.mergeAll( const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), ); const AuthLayerLive = EnvironmentAuth.layer.pipe( @@ -381,7 +384,11 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), -).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); +).pipe( + Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(ServerSelfUpdate.layer), + Layer.provide(browserApiCorsLayer), +); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..5daf7676d60 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -208,6 +208,61 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () => + // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as + // {id, name} only. These entries must decode instead of being dropped, + // with nameWithOwner rebuilt from the owner login. + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "OPEN", + mergedAt: null, + isCrossRepository: false, + headRepository: { + id: "R_kgDORLtfbQ", + name: "codething-mvp", + }, + headRepositoryOwner: { + id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3", + login: "pingdotgg", + }, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "t3code/codex-turn-mapping", + }); + + assert.deepStrictEqual(result, [ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index d9dcb7f9ad1..ded3c0a90b0 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -30,17 +30,21 @@ const GitHubPullRequestSchema = Schema.Struct({ mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), + // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was + // added later. Both fields stay optional so a version-drifted gh CLI can + // never fail the decode and silently drop the PR from the list. headRepository: Schema.optional( Schema.NullOr( Schema.Struct({ - nameWithOwner: Schema.String, + nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), headRepositoryOwner: Schema.optional( Schema.NullOr( Schema.Struct({ - login: Schema.String, + login: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), @@ -71,11 +75,15 @@ function normalizeGitHubPullRequestState(input: { function normalizeGitHubPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedGitHubPullRequestRecord { - const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const headRepositoryName = trimOptionalString(raw.headRepository?.name); const headRepositoryOwnerLogin = trimOptionalString(raw.headRepositoryOwner?.login) ?? - (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") - ? (headRepositoryNameWithOwner.split("/")[0] ?? null) + (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); + const headRepositoryNameWithOwner = + explicitNameWithOwner ?? + (headRepositoryOwnerLogin && headRepositoryName + ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); return { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9ffd3ed696d..7b5956de07f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -690,6 +690,61 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); + describe("remote operations", () => { + it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + + const reusedForSsh = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "git@github.com:pingdotgg/t3code.git", + }); + assert.equal(reusedForSsh, "origin"); + + const reusedForSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com/pingdotgg/t3code", + }); + assert.equal(reusedForSshScheme, "origin"); + + const reusedForBareSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://github.com/pingdotgg/t3code", + }); + assert.equal(reusedForBareSshScheme, "origin"); + + const reusedForSshPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code", + }); + assert.equal(reusedForSshPort, "origin"); + + const reusedForSshWithPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code.git", + }); + assert.equal(reusedForSshWithPort, "origin"); + + const addedForFork = yield* driver.ensureRemote({ + cwd, + preferredName: "octocat", + url: "git@github.com:octocat/t3code.git", + }); + assert.equal(addedForFork, "octocat"); + assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); + }), + ); + }); + describe("commit context", () => { it.effect("stages selected files and commits only those files", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 471ec10b566..33eb6d40d76 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -25,7 +25,7 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -235,14 +235,6 @@ function sanitizeRemoteName(value: string): string { return sanitized.length > 0 ? sanitized : "fork"; } -function normalizeRemoteUrl(value: string): string { - return value - .trim() - .replace(/\/+$/g, "") - .replace(/\.git$/i, "") - .toLowerCase(); -} - function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); for (const line of stdout.split("\n")) { @@ -1092,7 +1084,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "ensureRemote", )(function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); const remoteFetchUrls = yield* runGitStdout( "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, @@ -1100,7 +1092,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { return remoteName; } } diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 032e48e4612..ee595b1f836 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -94,6 +94,11 @@ function makeTestLayer(state: { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), }), ), ); @@ -206,6 +211,11 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), }), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c238154f58c..b02ca9deb66 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -366,10 +366,9 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { - concurrency: "unbounded", - discard: true, - }); + // invalidateStatus (not the two partial invalidations) so an explicit + // refresh also bypasses GitManager's slow PR-lookup cache. + yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 15c4c396c08..5739b6f58b1 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -158,9 +158,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const result = yield* workspaceEntries.listSkills({ cwd }); - expect(result.skills).toEqual([ - { name: "tidy", path: expect.stringContaining("tidy") }, - ]); + expect(result.skills).toEqual([{ name: "tidy", path: expect.stringContaining("tidy") }]); }), ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 2aad691a2b6..00d8c73d88c 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -205,7 +205,9 @@ it.effect("surfaces dotfiles even when the native results already fill the entry Effect.scoped( Effect.gen(function* () { const cwd = yield* Effect.acquireRelease( - Effect.tryPromise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-dotfiles-cap-"))), + Effect.tryPromise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-dotfiles-cap-")), + ), (directory) => Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d39ee569147..722c93f6889 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -70,6 +70,10 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import { + projectActivityEvent, + projectThreadDetailSnapshot, +} from "./orchestration/ActivityPayloadProjection.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -81,6 +85,7 @@ import { import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { getClaudeAccountUsage } from "./provider/claudeAccountUsage.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -306,6 +311,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetStockQuote, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], + [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], @@ -429,6 +435,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -1222,6 +1229,7 @@ const makeWsRpcLayer = ( ).pipe( Effect.map((events) => Array.from(events)), Effect.flatMap(enrichOrchestrationEvents), + Effect.map((events) => events.map(projectActivityEvent)), Effect.mapError( (cause) => new OrchestrationReplayEventsError({ @@ -1369,7 +1377,7 @@ const makeWsRpcLayer = ( Stream.filter(isThisThreadDetailEvent), Stream.map((event) => ({ kind: "event" as const, - event, + event: projectActivityEvent(event), })), ); @@ -1404,7 +1412,10 @@ const makeWsRpcLayer = ( .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) .pipe( Stream.filter(isThisThreadDetailEvent), - Stream.map((event) => ({ kind: "event" as const, event })), + Stream.map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), Stream.mapError( (cause) => new OrchestrationGetSnapshotError({ @@ -1456,7 +1467,7 @@ const makeWsRpcLayer = ( return Stream.concat( Stream.make({ kind: "snapshot" as const, - snapshot: snapshot.value, + snapshot: projectThreadDetailSnapshot(snapshot.value), }), afterSnapshot, ); @@ -1492,6 +1503,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverUpdateServer]: (input) => + observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, @@ -1689,11 +1704,11 @@ const makeWsRpcLayer = ( [WS_METHODS.projectsListSkills]: (input) => observeRpcEffect( WS_METHODS.projectsListSkills, - workspaceEntries.listSkills(input).pipe( - Effect.mapError( - (cause) => new ProjectListSkillsError({ cwd: input.cwd, cause }), + workspaceEntries + .listSkills(input) + .pipe( + Effect.mapError((cause) => new ProjectListSkillsError({ cwd: input.cwd, cause })), ), - ), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsReadFile]: (input) => @@ -2135,6 +2150,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; return HttpRouter.add( "GET", "/ws", @@ -2157,6 +2173,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts new file mode 100644 index 00000000000..6552dfb140c --- /dev/null +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -0,0 +1,233 @@ +import { + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThread, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildThreadFeed, type ThreadFeedActivity } from "../../mobile/src/lib/threadActivity.ts"; +import { deriveWorkLogEntries } from "../../web/src/session-logic.ts"; +import { + projectActivityEvent, + projectActivityPayload, + projectThreadDetailSnapshot, +} from "../src/orchestration/ActivityPayloadProjection.ts"; + +function makeActivity( + id: string, + itemType: string, + data: Record, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "tool", + kind: "tool.completed", + summary: `Completed ${itemType}`, + payload: { + itemType, + title: itemType, + detail: `${itemType} detail`, + status: "completed", + requestKind: "command", + data, + }, + turnId: TurnId.make(`turn-${id}`), + createdAt: "2026-07-27T00:00:00.000Z", + }; +} + +function makeThread(activities: ReadonlyArray): OrchestrationThread { + return { + id: ThreadId.make("thread-projection"), + projectId: ProjectId.make("project-projection"), + title: "Activity projection", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-07-27T00:00:00.000Z", + updatedAt: "2026-07-27T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities, + checkpoints: [], + session: null, + }; +} + +const fixtures = [ + makeActivity("command", "command_execution", { + item: { + command: ["bash", "-lc", "pnpm test"], + input: { command: "fallback input", ignored: "input bulk" }, + result: { command: "fallback result", aggregatedOutput: "x".repeat(10_000) }, + commandActions: [{ type: "unknown", output: "y".repeat(5_000) }], + }, + command: "fallback data", + kind: "execute", + toolCallId: "tool-command", + rawOutput: { + content: "\n```\nfirst useful line\nsecond line", + stdout: "unused stdout", + ignored: "raw bulk", + }, + ignored: "top-level bulk", + }), + makeActivity("file-change", "file_change", { + item: { + changes: [ + { oldPath: "src/old.ts", newPath: "src/new.ts", patch: "large patch".repeat(1_000) }, + { filePath: "src/second.ts" }, + ], + }, + ignored: "top-level bulk", + }), + makeActivity("dynamic", "dynamic_tool_call", { + toolCallId: "tool-dynamic", + rawOutput: { + stdout: "dynamic summary\nlong output".repeat(1_000), + }, + ignored: "top-level bulk", + }), + makeActivity("collab", "collab_agent_tool_call", { + kind: "delegate", + rawOutput: { + content: "``` \n```", + stdout: "must not be used when content is present", + }, + ignored: "top-level bulk", + }), + makeActivity("mcp", "mcp_tool_call", { + item: { + server: "repository", + tool: "search", + arguments: { query: "activity projection" }, + aggregatedOutput: "mcp payload remains available", + }, + ignored: "MCP data is rendered verbatim", + }), + makeActivity("search", "web_search", { + rawOutput: { + totalFiles: 42, + truncated: true, + content: "ignored because totalFiles wins", + }, + ignored: "top-level bulk", + }), + makeActivity("image", "image_view", { + ignored: "top-level bulk", + }), +] satisfies ReadonlyArray; + +describe("projectActivityPayload", () => { + function comparableActivity(activity: ThreadFeedActivity) { + return { + ...activity, + fullDetail: activity.getFullDetail(), + copyText: activity.getCopyText(), + getFullDetail: undefined, + getCopyText: undefined, + }; + } + + function comparableThreadFeed(activities: ReadonlyArray) { + return buildThreadFeed(makeThread(activities)).map((entry) => + entry.type === "activity-group" + ? { + ...entry, + activities: entry.activities.map(comparableActivity), + } + : entry, + ); + } + + it("drops unread bulk while retaining command, file, tool, and summary inputs", () => { + const projected = projectActivityPayload(fixtures[0]!); + expect(projected.payload).toEqual({ + itemType: "command_execution", + title: "command_execution", + detail: "command_execution detail", + status: "completed", + requestKind: "command", + data: { + item: { + command: ["bash", "-lc", "pnpm test"], + input: { command: "fallback input" }, + result: { command: "fallback result" }, + }, + command: "fallback data", + toolCallId: "tool-command", + kind: "execute", + rawOutput: { content: "first useful line" }, + }, + }); + + expect(projectActivityPayload(fixtures[1]!).payload).toMatchObject({ + data: { + files: [{ path: "src/new.ts" }, { path: "src/old.ts" }, { path: "src/second.ts" }], + }, + }); + }); + + it("passes MCP tool data through unchanged", () => { + expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]); + }); + + it("keeps current web and mobile derived output identical for every tool item type", () => { + for (const activity of fixtures) { + const projected = projectActivityPayload(activity); + expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); + expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); + } + }); + + it("projects snapshot and event transports without mutating their sources", () => { + const activity = fixtures[0]!; + const thread = makeThread([activity]); + const snapshot = { snapshotSequence: 7, thread }; + const projectedSnapshot = projectThreadDetailSnapshot(snapshot); + + expect(projectedSnapshot.thread.activities[0]).not.toBe(activity); + expect(snapshot.thread.activities[0]).toBe(activity); + + const event = { + sequence: 8, + eventId: EventId.make("event-activity"), + aggregateKind: "thread", + aggregateId: thread.id, + occurredAt: "2026-07-27T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { + threadId: thread.id, + activity, + }, + } satisfies Extract; + + const projectedEvent = projectActivityEvent(event); + expect(projectedEvent).not.toBe(event); + expect( + projectedEvent.type === "thread.activity-appended" + ? projectedEvent.payload.activity + : undefined, + ).toEqual(projectActivityPayload(activity)); + expect(event.payload.activity).toBe(activity); + }); +}); diff --git a/apps/web/index.html b/apps/web/index.html index e2fca9b53f1..2b0c76c359a 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -16,10 +16,6 @@ const SCHEME_BG = { default: ["#ffffff", "#161616"], solarized: ["#fdf6e3", "#002b36"], - dracula: ["#fffbeb", "#282a36"], - gruvbox: ["#fbf1c7", "#282828"], - catppuccin: ["#eff1f5", "#1e1e2e"], - "tokyo-night": ["#e1e2e7", "#1a1b26"], }; const themeColorMeta = document.querySelector('meta[name="theme-color"]'); try { diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index de74cfa2a90..a9d3f541ff1 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -7,27 +7,56 @@ import { acquireBrowserSurface } from "./browserSurfaceStore"; export function BrowserSurfaceSlot(props: { readonly tabId: string; readonly visible: boolean; + readonly cornerRadius?: number; + readonly layoutVersion?: string | number; readonly className?: string; + readonly fitSourceContent?: boolean; }) { - const { tabId, visible, className } = props; + const { + tabId, + visible, + cornerRadius = 0, + layoutVersion, + className, + fitSourceContent = false, + } = props; const elementRef = useRef(null); + const presentationRef = useRef({ visible, cornerRadius }); + const updateRef = useRef<(() => void) | null>(null); useLayoutEffect(() => { const element = elementRef.current; if (!element) return; - const lease = acquireBrowserSurface(tabId); + let lease = acquireBrowserSurface(tabId, fitSourceContent); const update = () => { const rect = element.getBoundingClientRect(); - lease.present( + const presentation = presentationRef.current; + const presented = lease.present( { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)), }, - visible && rect.width > 0 && rect.height > 0, + presentation.visible && rect.width > 0 && rect.height > 0, + presentation.cornerRadius, ); + if (presentation.visible && !presented) { + lease.release(); + lease = acquireBrowserSurface(tabId, fitSourceContent); + lease.present( + { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }, + rect.width > 0 && rect.height > 0, + presentation.cornerRadius, + ); + } }; + updateRef.current = update; update(); const observer = new ResizeObserver(update); observer.observe(element); @@ -37,9 +66,15 @@ export function BrowserSurfaceSlot(props: { observer.disconnect(); window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); + if (updateRef.current === update) updateRef.current = null; lease.release(); }; - }, [tabId, visible]); + }, [fitSourceContent, tabId]); + + useLayoutEffect(() => { + presentationRef.current = { visible, cornerRadius }; + updateRef.current?.(); + }, [cornerRadius, layoutVersion, visible]); return
; } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index a72da49c6fa..1d8b1e7db07 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -8,15 +8,19 @@ import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn } from "~/lib/utils"; -import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; -import { browserViewportSettingKey } from "./browserViewportLayout"; +import { browserViewportSettingKey, resolveBrowserViewportLayout } from "./browserViewportLayout"; import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; import { useBrowserViewportResize } from "./useBrowserViewportResize"; +import { + INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, + planWebviewCrashRecovery, + type WebviewCrashRecoveryState, +} from "./webviewCrashRecovery"; interface ElectronWebview extends HTMLElement { src: string; @@ -46,12 +50,15 @@ export function HostedBrowserWebview(props: { const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); + const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [aspectRatioLocked, setAspectRatioLocked] = useState(false); - const activeRecordingTabId = useActiveBrowserRecordingTabId(); const presentation = useBrowserSurfaceStore( useShallow((state) => { const current = state.byTabId[tabId]; return { + cornerRadius: current?.cornerRadius ?? 0, + fitSourceContent: current?.fitSourceContent ?? false, + fittedSourceContent: current?.fittedSourceContent ?? null, rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), visible: current?.visible ?? false, }; @@ -60,11 +67,7 @@ export function HostedBrowserWebview(props: { usePreviewBridge({ threadRef, tabId }); useEffect(() => { - if (presentation.visible || activeRecordingTabId !== tabId) return; - void stopBrowserRecording(tabId).catch(() => undefined); - }, [activeRecordingTabId, presentation.visible, tabId]); - - useEffect(() => { + crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(tabId); tabLeaseRef.current = lease; return () => { @@ -73,6 +76,14 @@ export function HostedBrowserWebview(props: { }; }, [tabId]); + const [webviewGeneration, setWebviewGeneration] = useState(0); + const [recoverySrc, setRecoverySrc] = useState(initialSrc); + const latestUrlRef = useRef(initialUrl); + + useEffect(() => { + latestUrlRef.current = initialUrl; + }, [initialUrl]); + const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); @@ -83,6 +94,7 @@ export function HostedBrowserWebview(props: { const bridge = previewBridge; if (!webview || !config || !bridge) return; let disposed = false; + let recoveryTimeout: ReturnType | null = null; const register = () => { const lease = tabLeaseRef.current; if (!lease) return; @@ -102,15 +114,31 @@ export function HostedBrowserWebview(props: { } })(); }; + const recoverGuest = () => { + if (disposed || recoveryTimeout !== null) return; + const recovery = planWebviewCrashRecovery(crashRecoveryRef.current, Date.now()); + if (!recovery) return; + crashRecoveryRef.current = recovery.state; + recoveryTimeout = setTimeout(() => { + recoveryTimeout = null; + if (!disposed) { + setRecoverySrc(latestUrlRef.current ?? initialSrc); + setWebviewGeneration((generation) => generation + 1); + } + }, recovery.delayMs); + }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); + webview.addEventListener("render-process-gone", recoverGuest); register(); return () => { disposed = true; + if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); + webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, tabId]); + }, [config, initialSrc, tabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -132,14 +160,14 @@ export function HostedBrowserWebview(props: { } : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; const containerSize = active && lastRect ? lastRect : hiddenSize; - const deviceToolbarVisible = active && viewport._tag !== "fill"; + const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent; const { activeDrag, commitViewportChange, effectiveViewport, handleResizeKeyDown, handleResizePointerDown, - layout, + layout: viewportLayout, } = useBrowserViewportResize({ tabId, viewport, @@ -148,6 +176,38 @@ export function HostedBrowserWebview(props: { deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); + const fittedSourceViewport = + presentation.fitSourceContent && lastRect + ? presentation.fittedSourceContent + ? { + _tag: "freeform" as const, + width: Math.max( + 1, + Math.round( + presentation.fittedSourceContent.width / + presentation.fittedSourceContent.scale / + normalizedZoomFactor, + ), + ), + height: Math.max( + 1, + Math.round( + presentation.fittedSourceContent.height / + presentation.fittedSourceContent.scale / + normalizedZoomFactor, + ), + ), + } + : { + _tag: "freeform" as const, + width: viewport._tag === "fill" ? 1280 : viewport.width, + height: viewport._tag === "fill" ? 800 : viewport.height, + } + : null; + const layout = + fittedSourceViewport && lastRect + ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor) + : viewportLayout; const syncContentPresentation = useCallback(() => { const wrapper = wrapperRef.current; @@ -178,6 +238,7 @@ export function HostedBrowserWebview(props: { const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, + cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, }); @@ -201,8 +262,9 @@ export function HostedBrowserWebview(props: { /> ) : null} - {active && effectiveViewport._tag !== "fill" ? ( + {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? ( <> { - const events: string[] = []; - const surfaceState = { - byTabId: {} as Record, - }; - return { - events, - onFrame: vi.fn(() => vi.fn()), - registrySet: vi.fn((_atom: unknown, value: string | null) => { - events.push(value === null ? "clear" : `publish:${value}`); - }), - save: vi.fn(async () => ({ - id: "recording-test", - tabId: "recording-tab", - path: "/tmp/recording-test.webm", - mimeType: "video/webm" as const, - sizeBytes: 0, - createdAt: "2026-06-26T00:00:00.000Z", - })), - startScreencast: vi.fn(async () => { - events.push("start-screencast"); - }), - stopScreencast: vi.fn(async () => undefined), - surfaceState, - }; - }); +const { + events, + frameSubscription, + onFrame, + registrySet, + save, + startScreencast, + stopScreencast, + surfaceState, +} = vi.hoisted(() => { + const events: string[] = []; + type Frame = { + readonly tabId: string; + readonly data: string; + readonly width: number; + readonly height: number; + readonly receivedAt: string; + }; + const frameSubscription: { listener: ((frame: Frame) => void) | null } = { + listener: null, + }; + const surfaceState = { + byTabId: {} as Record, + }; + return { + events, + frameSubscription, + onFrame: vi.fn((listener: (frame: Frame) => void) => { + frameSubscription.listener = listener; + return () => { + if (frameSubscription.listener === listener) frameSubscription.listener = null; + }; + }), + registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { + events.push( + value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, + ); + }), + save: vi.fn(async (tabId: string) => ({ + id: "recording-test", + tabId, + path: "/tmp/recording-test.webm", + mimeType: "video/webm" as const, + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + })), + startScreencast: vi.fn(async (tabId: string) => { + events.push("start-screencast"); + const surface = surfaceState.byTabId[tabId] as + | { + readonly content?: { readonly width: number; readonly height: number }; + readonly rect?: { readonly width: number; readonly height: number }; + } + | undefined; + const size = surface?.content ?? surface?.rect; + frameSubscription.listener?.({ + tabId, + data: "initial-frame", + width: size?.width ?? 1280, + height: size?.height ?? 800, + receivedAt: "2026-06-26T00:00:00.000Z", + }); + }), + stopScreencast: vi.fn(async () => undefined), + surfaceState, + }; +}); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { @@ -45,10 +86,10 @@ vi.mock("./browserSurfaceStore", () => ({ })); import { + BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, BrowserRecordingConflictError, - BrowserRecordingOperationError, - BrowserRecordingRequiresVisibleTabError, + readActiveBrowserRecordingTabIds, startBrowserRecording, stopBrowserRecording, } from "./browserRecording"; @@ -80,9 +121,20 @@ class FakeMediaRecorder { } } +const emitRecordingFrame = () => { + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "startup-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:00.000Z", + }); +}; + describe("browser recording", () => { beforeEach(() => { events.length = 0; + frameSubscription.listener = null; surfaceState.byTabId = { "recording-tab": { visible: true, @@ -93,12 +145,26 @@ describe("browser recording", () => { vi.clearAllMocks(); vi.stubGlobal("window", globalThis); vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); + class ImmediateImage { + private loadListener: EventListenerOrEventListenerObject | undefined; + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === "load") this.loadListener = listener; + } + + set src(_value: string) { + const event = new Event("load"); + if (typeof this.loadListener === "function") this.loadListener(event); + else this.loadListener?.handleEvent(event); + } + } + vi.stubGlobal("Image", ImmediateImage as unknown as typeof Image); vi.stubGlobal("document", { createElement: () => ({ width: 0, height: 0, captureStream: () => ({}), - getContext: () => ({ drawImage: vi.fn() }), + getContext: () => ({ drawImage: vi.fn(), fillRect: vi.fn(), fillStyle: "" }), }), }); }); @@ -116,7 +182,7 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); - it("rejects recording for a hidden tab before starting screencast", async () => { + it("records a hidden tab without requiring it to become visible", async () => { surfaceState.byTabId = { "recording-tab": { visible: false, @@ -125,18 +191,189 @@ describe("browser recording", () => { }, }; - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingRequiresVisibleTabError, + await startBrowserRecording("recording-tab"); + + expect(startScreencast).toHaveBeenCalledWith("recording-tab"); + expect(events).toEqual(["start-screencast", "publish:recording-tab"]); + + await stopBrowserRecording("recording-tab"); + }); + + it("fails startup instead of locking a fallback size when no frame arrives", async () => { + vi.useFakeTimers(); + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + }); + + const startPromise = startBrowserRecording("recording-tab"); + const rejection = expect(startPromise).rejects.toMatchObject({ + operation: "wait-first-frame", + tabId: "recording-tab", + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); + + await rejection; + expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); + expect(events.at(-1)).toBe("clear"); + }); + + it("fixes hidden recording dimensions before MediaRecorder starts", async () => { + const drawImage = vi.fn(); + const fillRect = vi.fn(); + let capturedStreamSize: { readonly width: number; readonly height: number } | undefined; + const canvas = { + width: 0, + height: 0, + captureStream: () => { + capturedStreamSize = { width: canvas.width, height: canvas.height }; + return {}; + }, + getContext: () => ({ drawImage, fillRect, fillStyle: "" }), + }; + vi.stubGlobal("document", { + createElement: () => canvas, + }); + surfaceState.byTabId = {}; + startScreencast.mockImplementationOnce(async (tabId: string) => { + events.push("start-screencast"); + frameSubscription.listener?.({ + tabId, + data: "captured-frame", + width: 390, + height: 844, + receivedAt: "2026-06-26T00:00:00.000Z", + }); + }); + + await startBrowserRecording("recording-tab"); + + expect(canvas).toMatchObject({ width: 390, height: 844 }); + expect(capturedStreamSize).toEqual({ width: 390, height: 844 }); + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 390, 844); + + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "different-sized-frame", + width: 1280, + height: 720, + receivedAt: "2026-06-26T00:00:01.000Z", + }); + + expect(canvas).toMatchObject({ width: 390, height: 844 }); + expect(fillRect).toHaveBeenLastCalledWith(0, 0, 390, 844); + + await stopBrowserRecording("recording-tab"); + }); + + it("draws the newest decoded frames without starving behind decode latency", async () => { + const drawImage = vi.fn(); + class DeferredImage { + static readonly instances: DeferredImage[] = []; + private loadListener: EventListenerOrEventListenerObject | undefined; + + constructor() { + DeferredImage.instances.push(this); + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === "load") this.loadListener = listener; + } + + set src(_value: string) {} + + finishLoading(): void { + const event = new Event("load"); + if (typeof this.loadListener === "function") this.loadListener(event); + else this.loadListener?.handleEvent(event); + } + } + vi.stubGlobal("Image", DeferredImage as unknown as typeof Image); + vi.stubGlobal("document", { + createElement: () => ({ + width: 0, + height: 0, + captureStream: () => ({}), + getContext: () => ({ drawImage, fillRect: vi.fn(), fillStyle: "" }), + }), + }); + + await startBrowserRecording("recording-tab"); + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "second-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:01.000Z", + }); + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "third-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:02.000Z", + }); + + DeferredImage.instances[1]?.finishLoading(); + expect(drawImage).toHaveBeenCalledOnce(); + DeferredImage.instances[2]?.finishLoading(); + expect(drawImage).toHaveBeenCalledTimes(2); + DeferredImage.instances[0]?.finishLoading(); + expect(drawImage).toHaveBeenCalledTimes(2); + + await stopBrowserRecording("recording-tab"); + }); + + it("records separate tabs concurrently", async () => { + const firstThreadRef = { + environmentId: EnvironmentId.make("environment-recording"), + threadId: ThreadId.make("thread-recording-first"), + }; + const secondThreadRef = { + environmentId: EnvironmentId.make("environment-recording"), + threadId: ThreadId.make("thread-recording-second"), + }; + surfaceState.byTabId = { + ...surfaceState.byTabId, + "recording-tab-2": { + visible: false, + rect: { x: 0, y: 0, width: 390, height: 844 }, + content: { x: 0, y: 0, width: 390, height: 844, scale: 1, scrollLeft: 0, scrollTop: 0 }, + }, + }; + + await Promise.all([ + startBrowserRecording("recording-tab", firstThreadRef), + startBrowserRecording("recording-tab-2", secondThreadRef), + ]); + + expect(startScreencast).toHaveBeenCalledTimes(2); + expect(onFrame).toHaveBeenCalledOnce(); + expect(events).toContain("publish:recording-tab,recording-tab-2"); + expect(readActiveBrowserRecordingTabIds()).toEqual( + new Set(["recording-tab", "recording-tab-2"]), ); + expect(readActiveBrowserRecordingTabIds(firstThreadRef)).toEqual(new Set(["recording-tab"])); + expect(readActiveBrowserRecordingTabIds(secondThreadRef)).toEqual(new Set(["recording-tab-2"])); - expect(startScreencast).not.toHaveBeenCalled(); - expect(registrySet).not.toHaveBeenCalled(); + await stopBrowserRecording("recording-tab"); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab-2"])); + await stopBrowserRecording("recording-tab-2"); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(save).toHaveBeenCalledTimes(2); }); it("does not report success for a second start while the first is still starting", async () => { let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { + startScreencast.mockImplementationOnce(async (tabId: string) => { events.push("start-screencast"); + frameSubscription.listener?.({ + tabId, + data: "initial-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:00.000Z", + }); await new Promise((resolve) => { finishStartingScreencast = resolve; }); @@ -197,28 +434,27 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledOnce(); }); - it("stops a screencast that finishes starting after cancellation", async () => { + it("finishes startup before stopping so an active recording yields an artifact", async () => { let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); await new Promise((resolve) => { finishStartingScreencast = resolve; }); + emitRecordingFrame(); }); const startPromise = startBrowserRecording("recording-tab"); - const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( - BrowserRecordingOperationError, - ); await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); const stopPromise = stopBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); + expect(stopScreencast).not.toHaveBeenCalled(); finishStartingScreencast?.(); - await rejectedStart; - await stopPromise; - expect(stopScreencast).toHaveBeenCalledTimes(2); + await startPromise; + await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); + expect(stopScreencast).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledOnce(); expect(events.at(-1)).toBe("clear"); }); @@ -229,12 +465,10 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); + emitRecordingFrame(); }); const firstStart = startBrowserRecording("recording-tab"); - const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf( - BrowserRecordingOperationError, - ); await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); const stopPromise = stopBrowserRecording("recording-tab"); @@ -243,7 +477,7 @@ describe("browser recording", () => { const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; finishStartingScreencast?.(); - await rejectedFirstStart; + await firstStart; await stopPromise; await restartAfterStop; await stopBrowserRecording("recording-tab"); @@ -251,6 +485,39 @@ describe("browser recording", () => { expect(startCallsBeforeFirstSettled).toBe(1); }); + it("keeps the recording slot while a failed stop waits for startup", async () => { + let finishStartingScreencast: (() => void) | undefined; + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + await new Promise((resolve) => { + finishStartingScreencast = resolve; + }); + emitRecordingFrame(); + }); + stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); + + const stopPromise = stopBrowserRecording("recording-tab"); + const rejectedStop = expect(stopPromise).rejects.toMatchObject({ + operation: "stop-screencast", + tabId: "recording-tab", + }); + expect(stopScreencast).not.toHaveBeenCalled(); + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingConflictError, + ); + + finishStartingScreencast?.(); + await firstStart; + await rejectedStop; + expect(stopScreencast).toHaveBeenCalledOnce(); + + await startBrowserRecording("recording-tab"); + await stopBrowserRecording("recording-tab"); + }); + it("fails a stop that waits too long for startup without freeing the recording slot", async () => { vi.useFakeTimers(); let finishStartingScreencast: (() => void) | undefined; @@ -259,18 +526,16 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); + emitRecordingFrame(); }); const startPromise = startBrowserRecording("recording-tab"); - const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( - BrowserRecordingOperationError, - ); expect(startScreencast).toHaveBeenCalledOnce(); const stopPromise = stopBrowserRecording("recording-tab"); await Promise.resolve(); await Promise.resolve(); - expect(stopScreencast).toHaveBeenCalledOnce(); + expect(stopScreencast).not.toHaveBeenCalled(); const rejection = expect(stopPromise).rejects.toMatchObject({ operation: "wait-startup", @@ -285,9 +550,10 @@ describe("browser recording", () => { ); finishStartingScreencast?.(); - await rejectedStart; + await startPromise; const cleanupResult = await stopBrowserRecording("recording-tab"); expect(cleanupResult).toBeNull(); + expect(stopScreencast).toHaveBeenCalledOnce(); expect(save).not.toHaveBeenCalled(); expect(events.at(-1)).toBe("clear"); }); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 01da1e4a3c1..5b88dfd4133 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,6 +1,7 @@ import type { DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, + ScopedThreadRef, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; @@ -46,17 +47,6 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl } } -export class BrowserRecordingRequiresVisibleTabError extends Schema.TaggedErrorClass()( - "BrowserRecordingRequiresVisibleTabError", - { - tabId: Schema.String, - }, -) { - override get message(): string { - return `Browser recording requires tab ${this.tabId} to be visible.`; - } -} - export class BrowserRecordingOperationError extends Schema.TaggedErrorClass()( "BrowserRecordingOperationError", { @@ -66,6 +56,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass; + readonly firstFrameSize: Promise<"frame" | "cancelled">; + readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void; + recorder: MediaRecorder | null; + mimeType: string | null; + frameSizeEstablished: boolean; + frameSequence: number; + lastDrawnFrameSequence: number; lifecycle: BrowserRecordingLifecycle; } -const activeBrowserRecordingTabIdAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("preview:active-browser-recording-tab"), -); +interface ActiveBrowserRecordingIndex { + readonly tabIds: ReadonlySet; +} -export function useActiveBrowserRecordingTabId(): string | null { - return useAtomValue(activeBrowserRecordingTabIdAtom); +const activeBrowserRecordingTabIdsAtom = Atom.make({ + tabIds: new Set(), +}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-browser-recording-tabs")); + +export function useActiveBrowserRecordingTabIds(): ReadonlySet { + return useAtomValue(activeBrowserRecordingTabIdsAtom).tabIds; } -let active: ActiveRecording | null = null; +const activeRecordings = new Map(); let unsubscribeFrames: (() => void) | null = null; export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; +export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; -export function readActiveBrowserRecordingTabId(): string | null { - return active?.tabId ?? null; +export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet { + const tabIds = new Set(); + for (const recording of activeRecordings.values()) { + if ( + threadRef === undefined || + (recording.threadRef?.environmentId === threadRef.environmentId && + recording.threadRef.threadId === threadRef.threadId) + ) { + tabIds.add(recording.tabId); + } + } + return tabIds; } const preferredMimeType = (): string => { @@ -126,22 +137,52 @@ const preferredMimeType = (): string => { }; const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { - const recording = active; - if (!recording || recording.tabId !== frame.tabId) return; + const recording = activeRecordings.get(frame.tabId); + if (!recording) return; + if ( + !Number.isFinite(frame.width) || + !Number.isFinite(frame.height) || + frame.width <= 0 || + frame.height <= 0 + ) { + return; + } + const width = Math.max(1, Math.round(frame.width)); + const height = Math.max(1, Math.round(frame.height)); + if (!recording.frameSizeEstablished) { + recording.canvas.width = width; + recording.canvas.height = height; + recording.frameSizeEstablished = true; + recording.settleFirstFrameSize("frame"); + } + const frameSequence = ++recording.frameSequence; const image = new Image(); image.addEventListener( "load", () => { - if (active !== recording) return; - recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); + if ( + activeRecordings.get(frame.tabId) !== recording || + frameSequence <= recording.lastDrawnFrameSequence + ) { + return; + } + recording.lastDrawnFrameSequence = frameSequence; + const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height); + const targetWidth = width * scale; + const targetHeight = height * scale; + const targetX = (recording.canvas.width - targetWidth) / 2; + const targetY = (recording.canvas.height - targetHeight) / 2; + recording.context.fillStyle = "#000000"; + recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height); + recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight); }, { once: true }, ); image.src = `data:image/jpeg;base64,${frame.data}`; }; -const stopMediaRecorder = async (recorder: MediaRecorder): Promise => { - if (recorder.state === "inactive") return; +const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise => { + if (!recorder || recorder.state === "inactive") return; const stopped = new Promise((resolve) => recorder.addEventListener("stop", () => resolve(), { once: true }), ); @@ -150,11 +191,42 @@ const stopMediaRecorder = async (recorder: MediaRecorder): Promise => { }; const clearActiveRecording = (recording: ActiveRecording): void => { - if (active !== recording) return; - active = null; - unsubscribeFrames?.(); - unsubscribeFrames = null; - appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); + if (activeRecordings.get(recording.tabId) !== recording) return; + recording.settleFirstFrameSize("cancelled"); + activeRecordings.delete(recording.tabId); + if (activeRecordings.size === 0) { + unsubscribeFrames?.(); + unsubscribeFrames = null; + } + appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { + tabIds: new Set(activeRecordings.keys()), + }); +}; + +const cleanupFailedRecordingStart = async ( + bridge: NonNullable, + recording: ActiveRecording, +): Promise => { + const errors: unknown[] = []; + try { + await bridge.recording.stopScreencast(recording.tabId); + } catch (error) { + errors.push(error); + } + try { + await stopMediaRecorder(recording.recorder); + } catch (error) { + errors.push(error); + } finally { + clearActiveRecording(recording); + } + if (errors.length === 0) return undefined; + if (errors.length === 1) return errors[0]; + return new AggregateError( + errors, + `Browser recording startup cleanup failed for tab ${recording.tabId}.`, + { cause: errors[0] }, + ); }; const recordingStartupCancelledError = ( @@ -168,7 +240,20 @@ const recordingStartupCancelledError = ( }); const isRecordingStarting = (recording: ActiveRecording): boolean => - active === recording && recording.lifecycle.phase === "starting"; + activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting"; + +const waitForFirstFrameSize = async (recording: ActiveRecording): Promise => { + if (recording.frameSizeEstablished) return true; + let timeout: ReturnType | null = null; + const outcome = await Promise.race([ + recording.firstFrameSize, + new Promise<"timeout">((resolve) => { + timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); + }), + ]); + if (timeout !== null) clearTimeout(timeout); + return outcome === "frame"; +}; const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise => { let timeout: ReturnType | null = null; @@ -195,20 +280,23 @@ const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Prom const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; -export async function startBrowserRecording(tabId: string): Promise { +export async function startBrowserRecording( + tabId: string, + threadRef: ScopedThreadRef | null = null, +): Promise { const bridge = previewBridge; if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); - if (active) { - if (active.tabId === tabId && active.lifecycle.phase === "recording") { - return active.startedAt; + const activeRecording = activeRecordings.get(tabId); + if (activeRecording) { + if (activeRecording.lifecycle.phase === "recording") { + return activeRecording.startedAt; } throw new BrowserRecordingConflictError({ requestedTabId: tabId, - activeTabId: active.tabId, + activeTabId: activeRecording.tabId, }); } const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; - if (!surface?.visible) throw new BrowserRecordingRequiresVisibleTabError({ tabId }); const recordingSize = surface?.content ?? surface?.rect; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, recordingSize?.width ?? 1280); @@ -221,42 +309,34 @@ export async function startBrowserRecording(tabId: string): Promise { height: canvas.height, }); } - let mimeType: string; - let recorder: MediaRecorder; - try { - mimeType = preferredMimeType(); - recorder = new MediaRecorder(canvas.captureStream(12), { - mimeType, - videoBitsPerSecond: 4_000_000, - }); - } catch (cause) { - throw new BrowserRecordingOperationError({ - operation: "initialize-media-recorder", - tabId, - cause, - }); - } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; let settleStartup: (() => void) | undefined; const startupSettled = new Promise((resolve) => { settleStartup = resolve; }); - recorder.addEventListener("dataavailable", (event) => { - if (event.data.size > 0) chunks.push(event.data); + let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined; + const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => { + settleFirstFrameSize = resolve; }); const recording: ActiveRecording = { tabId, + threadRef, canvas, context, - recorder, chunks, - mimeType, startedAt, startupSettled, + firstFrameSize, + settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome), + recorder: null, + mimeType: null, + frameSizeEstablished: false, + frameSequence: 0, + lastDrawnFrameSequence: 0, lifecycle: { phase: "starting" }, }; - active = recording; + activeRecordings.set(tabId, recording); try { try { unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); @@ -268,47 +348,23 @@ export async function startBrowserRecording(tabId: string): Promise { cause, }); } - try { - recorder.start(1_000); - } catch (cause) { - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "start-media-recorder", - tabId, - cause, - }); - } - if (!isRecordingStarting(recording)) { - throw recordingStartupCancelledError(recording); - } try { await bridge.recording.startScreencast(tabId); } catch (cause) { if (!isRecordingStarting(recording)) { throw recordingStartupCancelledError(recording, cause); } - let cleanupCause: unknown; - try { - await stopMediaRecorder(recorder); - } catch (error) { - cleanupCause = error; - } finally { - clearActiveRecording(recording); - } + clearActiveRecording(recording); throw new BrowserRecordingOperationError({ operation: "start-screencast", tabId, - cause: - cleanupCause === undefined - ? cause - : new AggregateError( - [cause, cleanupCause], - `Browser recording start and cleanup failed for tab ${tabId}.`, - { cause }, - ), + cause, }); } - if (!isRecordingStarting(recording)) { + const throwIfStartupCancelled = async (): Promise => { + // A stop requested during startup should let startup finish so the + // caller receives a real artifact. Only replacement/removal cancels it. + if (activeRecordings.get(tabId) === recording) return; try { await bridge.recording.stopScreencast(tabId); } catch (cause) { @@ -322,9 +378,78 @@ export async function startBrowserRecording(tabId: string): Promise { ); } throw recordingStartupCancelledError(recording); + }; + await throwIfStartupCancelled(); + const hasFirstFrame = await waitForFirstFrameSize(recording); + await throwIfStartupCancelled(); + if (!hasFirstFrame) { + const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`); + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + throw new BrowserRecordingOperationError({ + operation: "wait-first-frame", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser recording frame wait and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + + let mimeType: string; + let recorder: MediaRecorder; + try { + mimeType = preferredMimeType(); + recorder = new MediaRecorder(canvas.captureStream(12), { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + recording.mimeType = mimeType; + recording.recorder = recorder; + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) chunks.push(event.data); + }); + } catch (cause) { + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + throw new BrowserRecordingOperationError({ + operation: "initialize-media-recorder", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser recording initialization and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); } - recording.lifecycle = { phase: "recording" }; - appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); + try { + recorder.start(1_000); + } catch (cause) { + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + throw new BrowserRecordingOperationError({ + operation: "start-media-recorder", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser media recorder start and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + if (recording.lifecycle.phase === "starting") { + recording.lifecycle = { phase: "recording" }; + } + appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { + tabIds: new Set(activeRecordings.keys()), + }); return startedAt; } finally { settleStartup?.(); @@ -334,12 +459,16 @@ export async function startBrowserRecording(tabId: string): Promise { const finalizeBrowserRecording = async ( bridge: NonNullable, recording: ActiveRecording, -): Promise => { +): Promise => { const { tabId } = recording; let result: - | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } + | { + readonly _tag: "Success"; + readonly artifact: DesktopPreviewRecordingArtifact | null; + } | { readonly _tag: "Failure"; readonly error: unknown }; try { + await waitForRecordingStartupToSettle(recording); try { await bridge.recording.stopScreencast(tabId); } catch (cause) { @@ -349,30 +478,33 @@ const finalizeBrowserRecording = async ( cause, }); } - await waitForRecordingStartupToSettle(recording); - try { - await stopMediaRecorder(recording.recorder); - } catch (cause) { - throw new BrowserRecordingOperationError({ - operation: "stop-media-recorder", - tabId, - cause, - }); - } - try { - const blob = new Blob(recording.chunks, { type: recording.mimeType }); - const artifact = await bridge.recording.save( - tabId, - recording.mimeType, - new Uint8Array(await blob.arrayBuffer()), - ); - result = { _tag: "Success", artifact }; - } catch (cause) { - throw new BrowserRecordingOperationError({ - operation: "save-artifact", - tabId, - cause, - }); + if (!recording.recorder || !recording.mimeType) { + result = { _tag: "Success", artifact: null }; + } else { + try { + await stopMediaRecorder(recording.recorder); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "stop-media-recorder", + tabId, + cause, + }); + } + try { + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const artifact = await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + result = { _tag: "Success", artifact }; + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "save-artifact", + tabId, + cause, + }); + } } } catch (error) { result = { _tag: "Failure", error }; @@ -434,14 +566,14 @@ export function stopBrowserRecording( tabId: string, ): Promise { const bridge = previewBridge; - const recording = active; - if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null); + const recording = activeRecordings.get(tabId); + if (!bridge || !recording) return Promise.resolve(null); if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; const stopPromise = Promise.resolve() .then(() => finalizeBrowserRecording(bridge, recording)) .catch((error) => { - if (isStartupWaitTimeout(error) && active === recording) { + if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) { const cleanupAfterStartup = recording.startupSettled.then(() => discardBrowserRecording(bridge, recording), ); diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts index 6b3adf2219e..304adf4f19e 100644 --- a/apps/web/src/browser/browserRecordingScope.test.ts +++ b/apps/web/src/browser/browserRecordingScope.test.ts @@ -3,14 +3,41 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; describe("resolveBrowserRecordingStopTarget", () => { - it("stops the active recording when no explicit tab was requested", () => { - expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); - expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); - expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); + it("stops the only active recording when the implicit browser target changed", () => { + expect(resolveBrowserRecordingStopTarget(new Set(["tab-recording"]), "tab-browsing")).toBe( + "tab-recording", + ); }); - it("only stops an explicitly requested tab when it owns the recording", () => { - expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a"); - expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull(); + it("prefers an implicit target that is actively recording", () => { + expect( + resolveBrowserRecordingStopTarget( + new Set(["tab-recording-a", "tab-recording-b"]), + "tab-recording-b", + ), + ).toBe("tab-recording-b"); + }); + + it("does not guess when multiple recordings are active and the implicit target is not one", () => { + expect( + resolveBrowserRecordingStopTarget( + new Set(["tab-recording-a", "tab-recording-b"]), + "tab-browsing", + ), + ).toBeNull(); + }); + + it("only stops an explicitly requested tab when that tab is recording", () => { + const activeTabIds = new Set(["tab-recording"]); + expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-browsing", "tab-recording")).toBe( + "tab-recording", + ); + expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-recording", "tab-browsing")).toBe( + null, + ); + }); + + it("returns null when no matching recording is active", () => { + expect(resolveBrowserRecordingStopTarget(new Set(), "tab-browsing")).toBeNull(); }); }); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts index 92f58016aa1..ffbcc19a545 100644 --- a/apps/web/src/browser/browserRecordingScope.ts +++ b/apps/web/src/browser/browserRecordingScope.ts @@ -1,7 +1,14 @@ export function resolveBrowserRecordingStopTarget( - activeTabId: string | null, - requestedTabId?: string, + activeTabIds: ReadonlySet, + implicitTabId: string | null, + explicitTabId?: string, ): string | null { - if (activeTabId === null) return null; - return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null; + if (explicitTabId !== undefined) { + return activeTabIds.has(explicitTabId) ? explicitTabId : null; + } + if (implicitTabId !== null && activeTabIds.has(implicitTabId)) { + return implicitTabId; + } + if (activeTabIds.size !== 1) return null; + return activeTabIds.values().next().value ?? null; } diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index ecfce8cb432..0379f1382e7 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -11,6 +11,62 @@ describe("browserSurfaceStore", () => { useBrowserSurfaceStore.setState({ byTabId: {} }); }); + it("freezes the source content dimensions for a fitted presentation", () => { + const tabId = "fitted-browser-surface"; + const sourceOwner = Symbol("source"); + const sourceContent = { + x: 10, + y: 20, + width: 1_280, + height: 720, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }; + useBrowserSurfaceStore.getState().claim(tabId, sourceOwner, false); + useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); + + const fittedLease = acquireBrowserSurface(tabId, true); + useBrowserSurfaceStore.getState().presentContent(tabId, { + ...sourceContent, + width: 360, + height: 203, + scale: 0.28125, + }); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( + sourceContent, + ); + fittedLease.release(); + }); + + it("freezes the first content dimensions when fitting starts before the browser is measured", () => { + const tabId = "pending-fitted-browser-surface"; + const fittedLease = acquireBrowserSurface(tabId, true); + const sourceContent = { + x: 0, + y: 0, + width: 1_280, + height: 720, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }; + + useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); + useBrowserSurfaceStore.getState().presentContent(tabId, { + ...sourceContent, + width: 320, + height: 180, + scale: 0.25, + }); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( + sourceContent, + ); + fittedLease.release(); + }); + it("tracks content dimensions for a browser that has never been visible", () => { const tabId = "hidden-browser-surface-content-test"; useBrowserSurfaceStore.getState().presentContent(tabId, { @@ -36,8 +92,26 @@ describe("browserSurfaceStore", () => { expect( resolveBrowserSurfacePanelRect( { - hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null }, - active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null }, + hidden: { + rect: staleRect, + visible: false, + content: null, + fittedSourceContent: null, + fitSourceContent: false, + cornerRadius: 0, + updatedAt: 1, + owner: null, + }, + active: { + rect: liveRect, + visible: true, + content: null, + fittedSourceContent: null, + fitSourceContent: false, + cornerRadius: 0, + updatedAt: 2, + owner: null, + }, }, "hidden", ), @@ -53,7 +127,7 @@ describe("browserSurfaceStore", () => { const liveLease = acquireBrowserSurface(tabId); liveLease.present(liveRect, true); - staleLease.present(staleRect, true); + expect(staleLease.present(staleRect, true)).toBe(false); staleLease.release(); expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ @@ -75,4 +149,27 @@ describe("browserSurfaceStore", () => { owner: null, }); }); + + it("clears fitted presentation state when its lease is released", () => { + const tabId = "released-fitted-browser-surface"; + const fittedLease = acquireBrowserSurface(tabId, true); + useBrowserSurfaceStore.getState().presentContent(tabId, { + x: 0, + y: 0, + width: 1_280, + height: 800, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }); + + fittedLease.release(); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + fittedSourceContent: null, + fitSourceContent: false, + owner: null, + visible: false, + }); + }); }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 58012a11a30..c9b7cae544c 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -11,6 +11,9 @@ export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; readonly content: BrowserSurfaceContentPresentation | null; + readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; + readonly fitSourceContent: boolean; + readonly cornerRadius: number; readonly updatedAt: number; readonly owner: symbol | null; } @@ -27,19 +30,20 @@ export interface BrowserSurfaceContentPresentation { interface BrowserSurfaceStoreState { readonly byTabId: Record; - readonly claim: (tabId: string, owner: symbol) => void; + readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; readonly present: ( tabId: string, owner: symbol, rect: BrowserSurfaceRect, visible: boolean, + cornerRadius: number, ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly release: (tabId: string, owner: symbol) => void; } export interface BrowserSurfaceLease { - readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void; + readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; readonly release: () => void; } @@ -72,7 +76,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): export const useBrowserSurfaceStore = create()((set) => ({ byTabId: {}, - claim: (tabId, owner) => + claim: (tabId, owner, fitSourceContent) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner === owner) return state; @@ -83,21 +87,31 @@ export const useBrowserSurfaceStore = create()((set) = rect: current?.rect ?? null, visible: false, content: current?.content ?? null, + fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, + fitSourceContent, + cornerRadius: current?.cornerRadius ?? 0, updatedAt: Date.now(), owner, }, }, }; }), - present: (tabId, owner, rect, visible) => + present: (tabId, owner, rect, visible, cornerRadius) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; - if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; + if ( + current && + current.visible === visible && + current.cornerRadius === cornerRadius && + rectEquals(current.rect, rect) + ) { + return state; + } return { byTabId: { ...state.byTabId, - [tabId]: { ...current, rect, visible, updatedAt: Date.now() }, + [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, }, }; }), @@ -112,6 +126,9 @@ export const useBrowserSurfaceStore = create()((set) = rect: null, visible: false, content, + fittedSourceContent: null, + fitSourceContent: false, + cornerRadius: 0, updatedAt: Date.now(), owner: null, }, @@ -134,7 +151,15 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, content, updatedAt: Date.now() }, + [tabId]: { + ...current, + content, + fittedSourceContent: + current.fitSourceContent && current.fittedSourceContent === null + ? content + : current.fittedSourceContent, + updatedAt: Date.now(), + }, }, }; }), @@ -145,21 +170,33 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null }, + [tabId]: { + ...current, + visible: false, + fittedSourceContent: null, + fitSourceContent: false, + updatedAt: Date.now(), + owner: null, + }, }, }; }), })); -export function acquireBrowserSurface(tabId: string): BrowserSurfaceLease { +export function acquireBrowserSurface( + tabId: string, + fitSourceContent = false, +): BrowserSurfaceLease { const owner = Symbol(`browser-surface:${tabId}`); let released = false; - useBrowserSurfaceStore.getState().claim(tabId, owner); + useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); return { - present: (rect, visible) => { - if (released) return; - useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible); + present: (rect, visible, cornerRadius = 0) => { + if (released) return false; + if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; + useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); + return true; }, release: () => { if (released) return; diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 1e3b1632bcc..8f7b2ca7d65 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,20 +1,32 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const { closeTab, createTab } = vi.hoisted(() => ({ - closeTab: vi.fn(async () => undefined), +const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({ + closeTab: vi.fn<(tabId: string) => Promise>(async () => undefined), createTab: vi.fn<() => Promise>(), + stopBrowserRecording: vi.fn(async () => null), })); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { closeTab, createTab }, })); +vi.mock("./browserRecording", () => ({ + stopBrowserRecording, +})); + import { acquireDesktopTab } from "./desktopTabLifetime"; describe("desktopTabLifetime", () => { beforeEach(() => { closeTab.mockClear(); createTab.mockClear(); + stopBrowserRecording.mockClear(); + vi.stubGlobal("window", globalThis); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); }); it("shares tab creation readiness across concurrent leases", async () => { @@ -42,4 +54,53 @@ describe("desktopTabLifetime", () => { await first.ready; expect(ready).toBe(true); }); + + it("stops recording before closing the final desktop tab lease", async () => { + vi.useFakeTimers(); + let resolveStop: (() => void) | undefined; + stopBrowserRecording.mockReturnValueOnce( + new Promise((resolve) => { + resolveStop = () => resolve(null); + }), + ); + createTab.mockResolvedValueOnce(undefined); + + const lease = acquireDesktopTab("tab_recording_cleanup"); + await lease.ready; + lease.release(); + await vi.advanceTimersByTimeAsync(0); + + expect(stopBrowserRecording).toHaveBeenCalledWith("tab_recording_cleanup"); + expect(closeTab).not.toHaveBeenCalled(); + + resolveStop?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(closeTab).toHaveBeenCalledWith("tab_recording_cleanup"); + }); + + it("waits for an in-flight close before recreating a reacquired tab", async () => { + vi.useFakeTimers(); + let resolveClose: (() => void) | undefined; + createTab.mockResolvedValue(undefined); + closeTab.mockReturnValueOnce( + new Promise((resolve) => { + resolveClose = resolve; + }), + ); + + const initial = acquireDesktopTab("tab_close_reacquire"); + await initial.ready; + initial.release(); + await vi.advanceTimersByTimeAsync(0); + + expect(closeTab).toHaveBeenCalledWith("tab_close_reacquire"); + + const reacquired = acquireDesktopTab("tab_close_reacquire"); + expect(createTab).toHaveBeenCalledTimes(1); + + resolveClose?.(); + await reacquired.ready; + expect(createTab).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts index d621f6dc30c..98dffda0ea0 100644 --- a/apps/web/src/browser/desktopTabLifetime.ts +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -1,5 +1,7 @@ import { previewBridge } from "~/components/preview/previewBridge"; +import { stopBrowserRecording } from "./browserRecording"; + interface DesktopTabLease { references: number; closeTimer: number | null; @@ -7,6 +9,26 @@ interface DesktopTabLease { } const leases = new Map(); +const pendingTabOperations = new Map>(); + +const enqueueDesktopTabOperation = ( + tabId: string, + operation: () => Promise | void, +): Promise => { + const previous = pendingTabOperations.get(tabId); + const pending = previous + ? previous.catch(() => undefined).then(operation) + : Promise.resolve(operation()); + pendingTabOperations.set(tabId, pending); + void pending + .finally(() => { + if (pendingTabOperations.get(tabId) === pending) { + pendingTabOperations.delete(tabId); + } + }) + .catch(() => undefined); + return pending; +}; export interface AcquiredDesktopTab { readonly ready: Promise; @@ -19,7 +41,7 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { ({ references: 0, closeTimer: null, - ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), + ready: enqueueDesktopTabOperation(tabId, () => previewBridge?.createTab(tabId)), } satisfies DesktopTabLease); if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); current.references += 1; @@ -37,7 +59,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { const latest = leases.get(tabId); if (!latest || latest.references > 0) return; leases.delete(tabId); - void previewBridge?.closeTab(tabId); + void enqueueDesktopTabOperation(tabId, async () => { + await stopBrowserRecording(tabId).catch(() => null); + await previewBridge?.closeTab(tabId); + }).catch(() => undefined); }, 0); }, }; diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 826684bb06f..d0298dcdee7 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -23,6 +23,23 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { }); }); + it("clips a floating webview to the mini-player frame", () => { + expect( + resolveHostedBrowserWebviewWrapperStyle({ + active: true, + cornerRadius: 12, + rect: { x: 12, y: 34, width: 360, height: 203 }, + hiddenSize: { width: 1280, height: 800 }, + }), + ).toMatchObject({ + left: 12, + top: 34, + width: 360, + height: 203, + borderRadius: 12, + }); + }); + it("keeps an inactive webview paintable while moving it offscreen", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index 4dade986e1f..f96f4af0462 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -12,6 +12,7 @@ export interface HostedBrowserWebviewWrapperStyle { readonly height: number; readonly zIndex: number; readonly pointerEvents: "auto" | "none"; + readonly borderRadius?: number; readonly visibility?: "visible"; } @@ -19,10 +20,11 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; + readonly cornerRadius?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { active, hiddenSize, rect } = input; + const { active, cornerRadius = 0, hiddenSize, rect } = input; if (active && rect) { return { left: rect.x, @@ -31,6 +33,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { height: rect.height, zIndex: 30, pointerEvents: "auto", + ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), }; } diff --git a/apps/web/src/browser/webviewCrashRecovery.test.ts b/apps/web/src/browser/webviewCrashRecovery.test.ts new file mode 100644 index 00000000000..a13e6ff06f6 --- /dev/null +++ b/apps/web/src/browser/webviewCrashRecovery.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, + planWebviewCrashRecovery, + WEBVIEW_CRASH_RECOVERY_WINDOW_MS, +} from "./webviewCrashRecovery"; + +describe("planWebviewCrashRecovery", () => { + it("backs off and stops after a bounded number of rapid crashes", () => { + const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000); + expect(first).not.toBeNull(); + expect(first?.delayMs).toBe(250); + + const second = planWebviewCrashRecovery(first!.state, 1_100); + expect(second).not.toBeNull(); + expect(second?.delayMs).toBe(500); + + const third = planWebviewCrashRecovery(second!.state, 1_200); + expect(third).not.toBeNull(); + expect(third?.delayMs).toBe(1_000); + + expect(planWebviewCrashRecovery(third!.state, 1_300)).toBeNull(); + }); + + it("allows recovery again after the crash window expires", () => { + const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000)!; + const second = planWebviewCrashRecovery(first.state, 1_100)!; + const third = planWebviewCrashRecovery(second.state, 1_200)!; + + expect(planWebviewCrashRecovery(third.state, 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS)).toEqual( + { + delayMs: 250, + state: { + attempts: 1, + windowStartedAt: 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS, + }, + }, + ); + }); +}); diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts new file mode 100644 index 00000000000..2267f4a812d --- /dev/null +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -0,0 +1,38 @@ +export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; +export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; +export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; + +export interface WebviewCrashRecoveryState { + readonly attempts: number; + readonly windowStartedAt: number | null; +} + +export interface WebviewCrashRecoveryPlan { + readonly delayMs: number; + readonly state: WebviewCrashRecoveryState; +} + +export const INITIAL_WEBVIEW_CRASH_RECOVERY_STATE: WebviewCrashRecoveryState = { + attempts: 0, + windowStartedAt: null, +}; + +export function planWebviewCrashRecovery( + state: WebviewCrashRecoveryState, + now: number, +): WebviewCrashRecoveryPlan | null { + const startsNewWindow = + state.windowStartedAt === null || + now - state.windowStartedAt >= WEBVIEW_CRASH_RECOVERY_WINDOW_MS; + const attempts = startsNewWindow ? 0 : state.attempts; + if (attempts >= WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS) return null; + + const nextAttempts = attempts + 1; + return { + delayMs: WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS * 2 ** attempts, + state: { + attempts: nextAttempts, + windowStartedAt: startsNewWindow ? now : state.windowStartedAt, + }, + }; +} diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index ec25fd104d1..22ff986afbd 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -154,6 +154,8 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { return `Relay could not link the environment (${error.reason}).`; case "RelayEnvironmentLinkUnavailableError": return `Relay cannot provision the managed endpoint (${error.reason}).`; + case "RelayEnvironmentLinkLimitExceededError": + return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; case "RelayAgentActivityPublishProofExpiredError": return "Relay rejected an expired agent activity publish proof."; case "RelayAgentActivityPublishProofInvalidError": diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts new file mode 100644 index 00000000000..2a953132992 --- /dev/null +++ b/apps/web/src/commandPaletteBus.ts @@ -0,0 +1,30 @@ +// Tiny event bus allowing components to programmatically open the command palette +// without owning its React state. +const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; + +export interface CommandPaletteOpenDetail { + readonly open?: "add-project" | "new-thread-in"; +} + +export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { + window.dispatchEvent( + new CustomEvent(COMMAND_PALETTE_OPEN_EVENT, detail ? { detail } : undefined), + ); +} + +export function onOpenCommandPalette( + listener: (detail: CommandPaletteOpenDetail) => void, +): () => void { + const handler = (event: Event) => { + listener((event as CustomEvent).detail ?? {}); + }; + window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); + return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); +} + +/** Read at event time so consumers do not subscribe to transient dialog state. */ +export function isCommandPaletteOpen(): boolean { + return ( + typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null + ); +} diff --git a/apps/web/src/commandPaletteContext.tsx b/apps/web/src/commandPaletteContext.tsx deleted file mode 100644 index 8dae5fed3b5..00000000000 --- a/apps/web/src/commandPaletteContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { createContext, use, type ReactNode } from "react"; - -const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null); - -export function OpenAddProjectCommandPaletteProvider(props: { - readonly children: ReactNode; - readonly openAddProject: () => void; -}) { - return ( - - {props.children} - - ); -} - -export function useOpenAddProjectCommandPalette(): () => void { - const openAddProject = use(OpenAddProjectCommandPaletteContext); - if (!openAddProject) { - throw new Error("Command palette actions must be used inside CommandPalette"); - } - return openAddProject; -} - -/** Read at event time so the chat tree does not subscribe to transient dialog state. */ -export function isCommandPaletteOpen(): boolean { - return ( - typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null - ); -} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 23459dd5b61..62a5bd1cf09 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -8,7 +8,9 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; +import { useClientSettings } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; +import ThreadSidebarV2 from "./SidebarV2"; import { ThemeToggle } from "./ThemeToggle"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { @@ -83,7 +85,7 @@ function SidebarControl() { "pointer-events-auto", isSidebarVisible && stageBackdropVariant && - "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!", + "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", )} aria-label="Toggle main sidebar" /> @@ -100,7 +102,13 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + // Settings routes render the settings nav, which lives in the v1 component + // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); + const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const useSidebarV2 = sidebarV2Enabled && !isOnSettings; + const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); @@ -159,7 +167,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - + {useSidebarV2 ? : } {children} diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 8291e5e006e..c4ae7b96694 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -11,6 +11,9 @@ import { resolveEnvModeLabel, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, + resolveLocalCheckoutBranchMismatch, + resolvePreviousWorktreeLabel, + resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -18,6 +21,91 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +describe("resolvePreviousWorktreeSeed", () => { + it("picks the most recently updated worktree thread", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/older", + worktreePath: "/repo/.t3/worktrees/older", + updatedAt: "2026-07-20T00:00:00.000Z", + }, + { + branch: "t3/newer", + worktreePath: "/repo/.t3/worktrees/newer", + updatedAt: "2026-07-22T00:00:00.000Z", + }, + { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, + ], + currentWorktreePath: null, + }), + ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); + }); + + it("skips the worktree the composer already points at", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/current", + worktreePath: "/repo/.t3/worktrees/current", + updatedAt: "2026-07-22T00:00:00.000Z", + }, + ], + currentWorktreePath: "/repo/.t3/worktrees/current", + }), + ).toBeNull(); + }); + + it("returns null when no thread has a worktree", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], + currentWorktreePath: null, + }), + ).toBeNull(); + }); + + it("ignores archived threads and threads with unparseable timestamps", () => { + expect( + resolvePreviousWorktreeSeed({ + threads: [ + { + branch: "t3/archived", + worktreePath: "/repo/.t3/worktrees/archived", + updatedAt: "2026-07-23T00:00:00.000Z", + archivedAt: "2026-07-23T01:00:00.000Z", + }, + { + branch: "t3/garbage-timestamp", + worktreePath: "/repo/.t3/worktrees/garbage", + updatedAt: "not-a-date", + }, + { + branch: "t3/live", + worktreePath: "/repo/.t3/worktrees/live", + updatedAt: "2026-07-21T00:00:00.000Z", + archivedAt: null, + }, + ], + currentWorktreePath: null, + }), + ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); + }); +}); + +describe("resolvePreviousWorktreeLabel", () => { + it("includes the branch when known", () => { + expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( + "Previous worktree (t3/fix-thing)", + ); + expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( + "Previous worktree", + ); + }); +}); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -85,6 +173,55 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveLocalCheckoutBranchMismatch", () => { + it("detects when a local thread is associated with a different branch than the checkout", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toEqual({ + threadBranch: "feature/thread", + currentBranch: "feature/current", + }); + }); + + it("ignores matching local checkout state", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/thread", + }), + ).toBeNull(); + }); + + it("ignores dedicated worktrees because their checkout is already thread-scoped", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: "/repo/.t3/worktrees/feature-thread", + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toBeNull(); + }); + + it("ignores new-worktree base selection before a worktree exists", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: null, + activeThreadBranch: "feature/base", + currentGitBranch: "main", + }), + ).toBeNull(); + }); +}); + describe("resolveEnvironmentOptionLabel", () => { it("prefers the primary environment's machine label", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index b16e1f590a9..8fe35fa464a 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, @@ -65,6 +66,53 @@ export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Worktree" : "Local checkout"; } +export interface PreviousWorktreeSeed { + branch: string | null; + worktreePath: string; +} + +// The most recently touched worktree in the project that the composer isn't +// already pointing at. Backs the "Previous worktree" entry in the workspace +// selector so a follow-up thread can hop back into the worktree you just +// worked in without hunting for its branch. Archived threads don't compete — +// the rest of the UI hides them, so their worktrees shouldn't resurface here. +export function resolvePreviousWorktreeSeed(input: { + threads: ReadonlyArray<{ + branch: string | null; + worktreePath: string | null; + updatedAt: string; + archivedAt?: string | null; + }>; + currentWorktreePath: string | null; +}): PreviousWorktreeSeed | null { + let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; + for (const thread of input.threads) { + if ( + !thread.worktreePath || + thread.worktreePath === input.currentWorktreePath || + (thread.archivedAt ?? null) !== null + ) { + continue; + } + const updatedAt = toSortableTimestamp(thread.updatedAt); + if (updatedAt === null) { + continue; + } + if (latest === null || updatedAt > latest.updatedAt) { + latest = { + branch: thread.branch, + worktreePath: thread.worktreePath, + updatedAt, + }; + } + } + return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; +} + +export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { + return seed.branch ? `Previous worktree (${seed.branch})` : "Previous worktree"; +} + export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; @@ -108,6 +156,22 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +export function resolveLocalCheckoutBranchMismatch(input: { + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + activeThreadBranch: string | null; + currentGitBranch: string | null; +}): { threadBranch: string; currentBranch: string } | null { + const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; + if (effectiveEnvMode !== "local" || activeWorktreePath !== null) { + return null; + } + if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) { + return null; + } + return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0354c2e0cd7..e703427d4b6 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,12 +6,13 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon, + HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useCallback, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { useProject, useThread } from "../state/entities"; +import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, @@ -20,6 +21,8 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + resolvePreviousWorktreeLabel, + resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; @@ -66,6 +69,8 @@ interface MobileRunContextSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + previousWorktreeLabel: string | null; + onUsePreviousWorktree: () => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -79,6 +84,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, + previousWorktreeLabel, + onUsePreviousWorktree, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, @@ -166,7 +173,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Workspace onEnvModeChange(value as EnvMode)} + onValueChange={(value) => { + if (value === "previous-worktree") { + onUsePreviousWorktree(); + return; + } + onEnvModeChange(value as EnvMode); + }} > @@ -186,6 +199,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} + {previousWorktreeLabel ? ( + + + + {previousWorktreeLabel} + + + ) : null} @@ -217,6 +238,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -234,6 +256,40 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + // "Previous worktree" hops a draft into the most recently active worktree + // of this project — the "keep going where I just was" follow-up flow. Only + // drafts can hop; started server threads have their workspace pinned. + const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; + const projectRefsForWorktreeLookup = useMemo( + () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), + [canUsePreviousWorktree, activeProjectRef], + ); + const projectThreads = useThreadShellsForProjectRefs(projectRefsForWorktreeLookup); + const previousWorktreeSeed = useMemo( + () => + canUsePreviousWorktree + ? resolvePreviousWorktreeSeed({ + threads: projectThreads, + currentWorktreePath: activeWorktreePath, + }) + : null, + [activeWorktreePath, canUsePreviousWorktree, projectThreads], + ); + const previousWorktreeLabel = previousWorktreeSeed + ? resolvePreviousWorktreeLabel(previousWorktreeSeed) + : null; + const onUsePreviousWorktree = useCallback(() => { + if (!previousWorktreeSeed || !activeProjectRef) return; + // Same shape the branch selector writes when picking a branch that + // already lives in a worktree: point the draft at the existing tree. + setDraftThreadContext(draftId ?? threadRef, { + branch: previousWorktreeSeed.branch, + worktreePath: previousWorktreeSeed.worktreePath, + envMode: "worktree", + projectRef: activeProjectRef, + }); + }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); + const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); @@ -248,7 +304,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( ) : (
@@ -280,6 +338,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + previousWorktreeLabel={previousWorktreeLabel} + onUsePreviousWorktree={onUsePreviousWorktree} />
)} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 67ae3a8187d..763de56d8c4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,7 +3,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { @@ -17,9 +17,12 @@ import { useRef, useState, useTransition, + type MouseEvent as ReactMouseEvent, } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; @@ -317,6 +320,45 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Branch actions // --------------------------------------------------------------------------- + const copyBranchName = useCallback((branchName: string) => { + void writeTextToClipboard(branchName, "branch name").then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: "Branch name copied", + description: branchName, + }); + }, + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy branch name", + description: toBranchActionErrorMessage(error), + }), + ); + }, + ); + }, []); + + const handleBranchContextMenu = useCallback( + (event: ReactMouseEvent, branchName: string | null) => { + if (!branchName) return; + const api = readLocalApi(); + if (!api) return; + event.preventDefault(); + event.stopPropagation(); + const items: ContextMenuItem<"copy-branch-name">[] = [ + { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + ]; + void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => { + if (action === "copy-branch-name") copyBranchName(branchName); + }); + }, + [copyBranchName], + ); + const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action(); @@ -431,6 +473,7 @@ export function BranchToolbarBranchSelector({ const worktreeBaseBranchCandidate = isInitialBranchesLoadPending ? null : (defaultBranchName ?? currentGitBranch); + useEffect(() => { if ( effectiveEnvMode !== "worktree" || @@ -547,7 +590,11 @@ export function BranchToolbarBranchSelector({ }); // PR pill shown next to the branch selector when the active branch has one. - const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null); + const branchPr = resolveThreadPr({ + threadBranch: resolvedActiveBranch, + gitStatus: branchStatusQuery.data ?? null, + hasDedicatedWorktree: activeWorktreePath !== null, + }); const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. @@ -624,6 +671,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} className="pe-1.5" onClick={() => selectBranch(refName)} + onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
{itemValue} @@ -674,15 +722,23 @@ export function BranchToolbarBranchSelector({ {branchPrTooltip} ) : null} - } - className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" - disabled={isInitialBranchesLoadPending || isBranchActionPending} + {/* Context menu lives on the wrapper: the disabled Button has + pointer-events-none, so the trigger itself never sees right-clicks + while refs are loading or a branch action is pending. */} + handleBranchContextMenu(event, resolvedActiveBranch)} > - - {triggerLabel} - - + } + className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" + disabled={isInitialBranchesLoadPending || isBranchActionPending} + > + + {triggerLabel} + + +
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..e915c27312c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,4 +1,4 @@ -import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; +import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { @@ -17,11 +17,15 @@ import { SelectValue, } from "./ui/select"; +export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; + interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + previousWorktreeLabel?: string | null; + onUsePreviousWorktree?: () => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -29,13 +33,19 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe effectiveEnvMode, activeWorktreePath, onEnvModeChange, + previousWorktreeLabel, + onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { + const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ...(showPreviousWorktree && previousWorktreeLabel + ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] + : []), ], - [activeWorktreePath], + [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], ); if (envLocked) { @@ -60,7 +70,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 58abe4a9ccd..f51ed3b1fab 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,10 @@ import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + resolveExternalWebLinkHost, + showExternalLinkContextMenu, +} from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, @@ -835,17 +840,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); -function resolveExternalLinkHost(href: string | undefined): string | null { - if (!href) return null; - try { - const url = new URL(href); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.hostname || null; - } catch { - return null; - } -} - const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( @@ -1397,7 +1391,7 @@ function ChatMarkdown({ const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { - const faviconHost = resolveExternalLinkHost(href); + const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -1414,37 +1408,30 @@ function ChatMarkdown({ } }} onContextMenu={(event) => { - if (!canOpenInPreview || !href) return; + if (!canOpenInPreview || !href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; - void (async () => { - let operation = "show-link-context-menu"; - try { - const clicked = await api.contextMenu.show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "open-in-browser") { - operation = "open-link-in-preview"; - const result = await openExternalLinkInPreview(href); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure({ operation, target: href }, result.cause); - } - return; + void showExternalLinkContextMenu({ + href, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInPreview: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); } - if (clicked === "open-external") { - operation = "open-link-external"; - await api.shell.openExternal(href); - } - } catch (cause) { + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); - } - })(); + }, + }); }} > {faviconHost ? ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bc7487cee29..57c12959ffb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,15 +12,20 @@ import type { Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -49,6 +54,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, @@ -77,6 +84,34 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("resolveThreadMetadataUpdateForNextTurn", () => { + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }; + + it("updates a stale local thread branch to the active checkout", () => { + expect( + resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: modelSelection, + currentBranch: "feature/thread", + nextBranch: "feature/checkout", + }), + ).toEqual({ branch: "feature/checkout", worktreePath: null }); + }); + + it("does not write metadata when the model and branch are unchanged", () => { + expect( + resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: modelSelection, + nextModelSelection: modelSelection, + currentBranch: "feature/current", + nextBranch: "feature/current", + }), + ).toBeNull(); + }); +}); + describe("buildThreadTurnInterruptInput", () => { it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); @@ -261,6 +296,61 @@ describe("resolveSendEnvMode", () => { }); }); +describe("branchMismatchKey", () => { + it("builds a key from thread id and both branches", () => { + expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( + "thread-1:feat/a:feat/b", + ); + }); + + it("returns null without a thread or mismatch", () => { + expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull(); + expect(branchMismatchKey("thread-1", null)).toBeNull(); + }); +}); + +describe("shouldShowBranchMismatchBanner", () => { + const base = { + hasMismatch: true, + isDismissed: false, + composerHasContent: false, + wasShownForCurrentMismatch: false, + }; + + it("stays hidden during passive browsing (even though the composer autofocuses)", () => { + expect(shouldShowBranchMismatchBanner(base)).toBe(false); + }); + + it("shows once the composer has draft content", () => { + expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true); + }); + + it("stays mounted after the draft clears once shown for the current mismatch", () => { + expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe( + true, + ); + }); + + it("never shows when dismissed or without a mismatch", () => { + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }), + ).toBe(false); + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }), + ).toBe(false); + }); +}); + +describe("session branch mismatch dismissal", () => { + it("tracks dismissed keys and treats other keys as active", () => { + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); + dismissBranchMismatchForSession("t1:a:b"); + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true); + expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false); + expect(isBranchMismatchDismissedForSession(null)).toBe(false); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps open threads and makes the active thread most recent", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 325f9afa90a..466c9b24c87 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -27,6 +27,33 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function resolveThreadMetadataUpdateForNextTurn(input: { + currentModelSelection: ModelSelection; + nextModelSelection?: ModelSelection; + currentBranch: string | null; + nextBranch?: string; +}): { + modelSelection?: ModelSelection; + branch?: string; + worktreePath?: null; +} | null { + const nextModelSelection = input.nextModelSelection; + const modelSelectionChanged = + nextModelSelection !== undefined && + (nextModelSelection.model !== input.currentModelSelection.model || + nextModelSelection.instanceId !== input.currentModelSelection.instanceId || + JSON.stringify(nextModelSelection.options ?? null) !== + JSON.stringify(input.currentModelSelection.options ?? null)); + const branchChanged = input.nextBranch !== undefined && input.nextBranch !== input.currentBranch; + if (!modelSelectionChanged && !branchChanged) { + return null; + } + return { + ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}), + ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}), + }; +} + export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, @@ -45,6 +72,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, @@ -260,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy( }; } +export function branchMismatchKey( + threadId: string | null, + mismatch: { threadBranch: string; currentBranch: string } | null, +): string | null { + if (!threadId || !mismatch) { + return null; + } + return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`; +} + +// The mismatch banner only matters when the user is about to send: passive +// reading of an old thread carries no risk (the branch picker tint already +// covers ambient awareness). Draft content is the intent signal — composer +// focus is useless here because ChatView autofocuses the composer on every +// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once +// revealed so it doesn't flicker away when the draft is cleared. +export function shouldShowBranchMismatchBanner(input: { + hasMismatch: boolean; + isDismissed: boolean; + composerHasContent: boolean; + wasShownForCurrentMismatch: boolean; +}): boolean { + if (!input.hasMismatch || input.isDismissed) { + return false; + } + return input.composerHasContent || input.wasShownForCurrentMismatch; +} + +// Session-scoped (module-level so it survives ChatView remounts, e.g. route +// changes). Durable cross-device dismissal is planned as a server-side ack. +const sessionDismissedBranchMismatchKeys = new Set(); + +export function dismissBranchMismatchForSession(key: string): void { + sessionDismissedBranchMismatchKeys.add(key); +} + +export function isBranchMismatchDismissedForSession(key: string | null): boolean { + return key !== null && sessionDismissedBranchMismatchKeys.has(key); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7035932a493..287d9d95197 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,9 +22,10 @@ import { TerminalOpenInput, } from "@t3tools/contracts"; import { - connectionStatusHeadline, + connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -112,7 +113,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; @@ -131,15 +132,27 @@ import { } from "../previewStateStore"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; +import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { + AlarmClockIcon, + CheckCircle2Icon, + ChevronDownIcon, + GitBranchIcon, + TriangleAlertIcon, + WifiOffIcon, +} from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -153,7 +166,9 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { useEnvironmentSettings } from "../hooks/useSettings"; +import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -189,6 +204,7 @@ import { useEnvironmentQuery } from "../state/query"; import { primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, + primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; @@ -218,10 +234,15 @@ import { } from "~/lib/contextWindow"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; -import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; +import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + getProviderStatusBannerKey, + ProviderStatusBanner, + shouldShowProviderStatusBanner, +} from "./chat/ProviderStatusBanner"; import { RestartRequestBanner } from "./chat/RestartRequestBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -233,13 +254,17 @@ import { } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, + shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -249,6 +274,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -261,11 +287,24 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, + serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -1084,6 +1123,10 @@ type LocalThreadErrorEntry = { readonly at: number; }; +function chatActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -1111,6 +1154,7 @@ function ChatViewContent(props: ChatViewProps) { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -1147,6 +1191,10 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.threadLastVisitedAtById[routeThreadKey], ); const settings = useEnvironmentSettings(environmentId); + // New-thread defaults live in the primary environment's settings.json (the + // settings UI never writes to remote environments), so read them from the + // primary server rather than the thread's environment. + const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1369,10 +1417,7 @@ function ChatViewContent(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], @@ -1469,6 +1514,9 @@ function ChatViewContent(props: ChatViewProps) { const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), + ); const panelTerminalIds = useMemo( () => new Set( @@ -1492,6 +1540,24 @@ function ChatViewContent(props: ChatViewProps) { .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); }, [activePreviewState.sessions, activeThreadRef]); + useEffect(() => { + if (!activeThreadRef || !activePreviewMiniPlayer) return; + const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); + const sameTabOpenInPanel = + previewPanelOpen && + activeRightPanelSurface?.kind === "preview" && + activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; + if (!miniTabStillExists || sameTabOpenInPanel) { + usePreviewMiniPlayerStore.getState().close(activeThreadRef); + } + }, [ + activePreviewMiniPlayer, + activePreviewState.sessions, + activeRightPanelSurface, + activeThreadRef, + previewPanelOpen, + ]); + const planSidebarOpen = activeRightPanelKind === "plan"; const existingOpenTerminalThreadKeys = useMemo(() => { @@ -1823,7 +1889,10 @@ function ChatViewContent(props: ChatViewProps) { hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; - const composerBannerItems = useMemo(() => { + const versionMismatchEnvironmentId = + versionMismatch && activeThread ? activeThread.environmentId : null; + const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { const connection = activeEnvironmentUnavailableState.connection; @@ -1834,7 +1903,7 @@ function ChatViewContent(props: ChatViewProps) { variant: connection.phase === "error" ? "error" : "warning", icon: , collapsible: true, - title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusHeadline(connection)}`, + title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`, description: connection.error ?? "Reconnect this environment before sending messages or running actions.", @@ -1862,7 +1931,12 @@ function ChatViewContent(props: ChatViewProps) { ), }); } - if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { + if ( + showVersionMismatchBanner && + versionMismatch && + versionMismatchDismissKey && + versionMismatchEnvironmentId + ) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, variant: "warning", @@ -1871,9 +1945,21 @@ function ChatViewContent(props: ChatViewProps) { description: ( <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} - {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. + {versionMismatch.serverVersion}.{" "} + {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} ), + // The desktop-managed guidance is already the description; the action + // slot would only repeat it. + actions: + versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + + ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); @@ -1886,15 +1972,18 @@ function ChatViewContent(props: ChatViewProps) { activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, navigate, + setDismissedVersionMismatchKey, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, + versionMismatchEnvironmentId, + versionMismatchSelfUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), + selectedProviderByThreadId ?? threadProvider, ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); @@ -2328,6 +2417,22 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); + const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< + string | null + >(null); + useEffect(() => { + if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) { + setDismissedProviderStatusBannerKey(null); + } + }, [dismissedProviderStatusBannerKey, providerStatusBannerKey]); + const visibleProviderStatus = shouldShowProviderStatusBanner( + activeProviderStatus, + dismissedProviderStatusBannerKey, + ) + ? activeProviderStatus + : null; + const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -2335,6 +2440,7 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const showComposerContextStrip = isGitRepo && activeProject !== null; const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -3264,6 +3370,7 @@ function ChatViewContent(props: ChatViewProps) { threadId: ThreadId; createdAt: string; modelSelection?: ModelSelection; + branch?: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }): Promise> => { @@ -3272,19 +3379,19 @@ function ChatViewContent(props: ChatViewProps) { } let result: AtomCommandResult = AsyncResult.success(undefined); - if ( - input.modelSelection !== undefined && - (input.modelSelection.model !== serverThread.modelSelection.model || - input.modelSelection.instanceId !== serverThread.modelSelection.instanceId || - JSON.stringify(input.modelSelection.options ?? null) !== - JSON.stringify(serverThread.modelSelection.options ?? null)) - ) { + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: serverThread.modelSelection, + ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}), + currentBranch: serverThread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), + }); + if (metadataUpdate) { result = mapAtomCommandResult( await updateThreadMetadata({ environmentId, input: { threadId: input.threadId, - modelSelection: input.modelSelection, + ...metadataUpdate, }, }), () => undefined, @@ -3772,12 +3879,341 @@ function ChatViewContent(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - settings.newWorktreesStartFromOrigin) + primaryServerSettings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, }); + const localCheckoutBranchMismatch = useMemo( + () => + isServerThread + ? resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: envMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch: gitStatusQuery.data?.refName ?? null, + }) + : null, + [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], + ); + // Settled state of the open thread, resolved exactly like the sidebar + // partition (same shell, same capability gate, same PR auto-settle input) + // so the banner and the sidebar row never disagree. + const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const activeThreadPr = resolveThreadPr({ + threadBranch: activeThread?.branch ?? null, + gitStatus: gitStatusQuery.data ?? null, + hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, + }); + const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; + const nowMinute = useNowMinute(); + const activeThreadSnoozed = + activeThreadShell !== null && + supportsSnooze && + effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + useEffect(() => { + void snoozeWakeTick; + if (!activeThreadSnoozed) return; + const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); + if (!Number.isFinite(wakeAtMs)) return; + const id = window.setTimeout( + () => bumpSnoozeWakeTick((tick) => tick + 1), + Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), + ); + return () => window.clearTimeout(id); + }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); + const activeThreadSettled = useMemo(() => { + if (activeThreadShell === null || !supportsSettlement) return false; + return effectiveSettled(activeThreadShell, { + now: `${nowMinute}:00.000Z`, + autoSettleAfterDays, + changeRequestState: activeThreadPr?.state ?? null, + }); + }, [ + activeThreadPr?.state, + activeThreadShell, + autoSettleAfterDays, + nowMinute, + supportsSettlement, + ]); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); + // Keyed by thread, not a boolean: the pending state must follow the thread + // it belongs to across navigation, and a request resolving for thread A + // must never clear (or re-enable) thread B's button. + const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); + const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; + const handleUnsettleActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsettlingThreadKey(threadKey); + try { + const result = await unsettleThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsettleThreadMutation]); + const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { + reportFailure: false, + }); + const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); + const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; + const handleUnsnoozeActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsnoozingThreadKey(threadKey); + try { + const result = await unsnoozeThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to wake thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsnoozeThreadMutation]); + const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); + const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); + // Once revealed for a given mismatch, the banner stays mounted until the + // mismatch changes or resolves, so clearing the draft doesn't flicker it. + const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); + // Dismissal lives in a module-level set (survives remounts); this tick just + // forces a re-render so the banner leaves immediately. + const [, setBranchMismatchDismissTick] = useState(0); + const composerHasDraftContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return Boolean( + draft && + (draft.prompt.trim().length > 0 || + draft.images.length > 0 || + draft.terminalContexts.length > 0 || + draft.elementContexts.length > 0 || + draft.previewAnnotations.length > 0 || + draft.reviewComments.length > 0), + ); + }); + const activeBranchMismatchKey = branchMismatchKey( + activeThread?.id ?? null, + localCheckoutBranchMismatch, + ); + const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ + hasMismatch: localCheckoutBranchMismatch !== null, + isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), + composerHasContent: composerHasDraftContent, + wasShownForCurrentMismatch: + revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, + }); + useEffect(() => { + setRevealedBranchMismatchKey((revealed) => { + if (showBranchMismatchBanner) { + return activeBranchMismatchKey; + } + // Hysteresis is scoped to an uninterrupted mismatch: reset when the + // mismatch resolves or changes so a recurrence re-gates on intent. + return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed; + }); + }, [activeBranchMismatchKey, showBranchMismatchBanner]); + const handleSwitchCheckoutToThread = useCallback(async () => { + if ( + !activeProjectCwd || + !activeThread || + !localCheckoutBranchMismatch || + isRestoringThreadBranch + ) { + return; + } + setIsRestoringThreadBranch(true); + const checkoutResult = await switchGitRef({ + environmentId, + input: { + cwd: activeProjectCwd, + refName: localCheckoutBranchMismatch.threadBranch, + }, + }); + if (checkoutResult._tag === "Failure") { + setIsRestoringThreadBranch(false); + if (!isAtomCommandInterrupted(checkoutResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch checkout", + description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), + }), + ); + } + return; + } + + const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; + if (nextBranch !== activeThread.branch) { + const updateResult = await updateThreadMetadata({ + environmentId, + input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, + }); + if (updateResult._tag === "Failure") { + setIsRestoringThreadBranch(false); + if (!isAtomCommandInterrupted(updateResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Checkout switched, but the thread could not be updated", + description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), + }), + ); + } + gitStatusQuery.refresh(); + return; + } + } + gitStatusQuery.refresh(); + setIsRestoringThreadBranch(false); + scheduleComposerFocus(); + }, [ + activeProjectCwd, + activeThread, + environmentId, + gitStatusQuery, + isRestoringThreadBranch, + localCheckoutBranchMismatch, + scheduleComposerFocus, + switchGitRef, + updateThreadMetadata, + ]); + // The stack renders items[0] front-most and tucks the rest behind hover, so + // ordering is priority: system banners, then the branch-mismatch notice, + // and the informational parked-thread banner last — it must never cover another. + const parkedThreadBannerItem = useMemo(() => { + if (!activeThreadSnoozed && !activeThreadSettled) { + return null; + } + const isSnoozed = activeThreadSnoozed; + return { + id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, + variant: "info", + icon: isSnoozed ? : , + title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, + description: isSnoozed + ? "Sending a message wakes it and moves it back to Active in the sidebar." + : "Sending a message moves it back to Active in the sidebar.", + actions: ( + + ), + }; + }, [ + activeThread?.id, + activeThreadSettled, + activeThreadSnoozed, + handleUnsnoozeActiveThread, + handleUnsettleActiveThread, + isUnsnoozing, + isUnsettling, + ]); + const handleRestoreThreadBranch = useCallback(() => { + if (gitStatusQuery.data?.hasWorkingTreeChanges) { + setBranchRestoreConfirmOpen(true); + return; + } + void handleSwitchCheckoutToThread(); + }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); + const composerBannerItems = useMemo(() => { + const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { + return [...systemComposerBannerItems, ...parkedThreadItems]; + } + return [ + ...systemComposerBannerItems, + { + id: `branch-mismatch:${activeBranchMismatchKey}`, + variant: "info", + icon: , + title: ( + + Branch changed — was + + + {localCheckoutBranchMismatch.threadBranch} + + } + /> + + This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will + continue on {localCheckoutBranchMismatch.currentBranch}. + + + + ), + className: "dark:shadow-none", + actions: ( + + ), + dismissLabel: "Dismiss branch change notice", + onDismiss: () => { + dismissBranchMismatchForSession(activeBranchMismatchKey); + setBranchMismatchDismissTick((tick) => tick + 1); + }, + }, + ...parkedThreadItems, + ]; + }, [ + activeBranchMismatchKey, + handleRestoreThreadBranch, + isRestoringThreadBranch, + localCheckoutBranchMismatch, + parkedThreadBannerItem, + showBranchMismatchBanner, + systemComposerBannerItems, + ]); useEffect(() => { setPendingServerThreadEnvMode(null); @@ -4076,7 +4512,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) return; + if (!sendCtx?.providerAvailable) return; const { images: composerImages, terminalContexts: composerTerminalContexts, @@ -4327,6 +4763,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode, }); @@ -4652,7 +5091,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { @@ -4708,6 +5147,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode: nextInteractionMode, }); @@ -4784,6 +5226,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, @@ -4811,7 +5254,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { @@ -5061,7 +5504,7 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -5073,7 +5516,7 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - settings.newWorktreesStartFromOrigin, + primaryServerSettings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, @@ -5240,7 +5683,7 @@ function ChatViewContent(props: ChatViewProps) {
- {/* Error banner */} - setThreadError(activeThread.id, null)} @@ -5295,6 +5737,13 @@ function ChatViewContent(props: ChatViewProps) {
{/* Chat column */}
+ {/* Provider status overlays the timeline without changing its content height. */} +
+ setDismissedProviderStatusBannerKey(providerStatusBannerKey)} + /> +
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} @@ -5331,6 +5780,7 @@ function ChatViewContent(props: ChatViewProps) { onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} + topFadeEnabled={!hasTimelineTopBanner} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5363,22 +5813,11 @@ function ChatViewContent(props: ChatViewProps) { : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2" } > - {!isDraftHeroState ? ( -