diff --git a/.mise.toml b/.mise.toml index 554d085a5..7c1a0bad9 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,9 +1,10 @@ [env] -ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0" +ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0" _.path = [ - "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools", - "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator", + "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin", + "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools", + "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator", ] [tools] -java = "17" +java = "21" diff --git a/.tool-versions b/.tool-versions index 82356b6df..7dfc8d848 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ rust 1.85.1 nodejs 22.20.0 java 21 -android-sdk latest +android-sdk 21.0 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 2358b7e4d..a6778364e 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -12,6 +12,10 @@ initializing → idle → running → idle (or error → closed) Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket. +### Cancellation + +Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn. + ## Relationships Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions: @@ -85,7 +89,9 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. -Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded. +Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. + +The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track. To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. @@ -105,7 +111,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway ### Subagent accumulation under long-lived parents -A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem. +A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts. ### Cross-client tab dismissal diff --git a/docs/android.md b/docs/android.md index 9ecc66063..ec75faa7c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -25,6 +25,38 @@ Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce ` The formula reserves three digits each for minor and patch. If either reaches `1000`, change the formula before cutting that release. +## Prerequisites (local dev) + +Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev): + +```bash +mise install # java 21 + android-sdk 21.0 command-line tools +``` + +> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`. + +`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon: + +```bash +sdkmanager --licenses +sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \ + "system-images;android-35;google_apis;arm64-v8a" +avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7 +emulator @paseo # start it; leave running +``` + +On an Intel Mac, use the `x86_64` system image: + +```bash +sdkmanager --licenses +sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \ + "system-images;android-35;google_apis;x86_64" +avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7 +emulator @paseo # start it; leave running +``` + +Gradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust `android-35` only if it asks for a different level. + ## Local build + install From repo root: @@ -50,6 +82,31 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release rm -rf android ``` +## Running on an emulator against a worktree daemon + +`npm run android` builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and **the emulator does not share the host's loopback**: `localhost` inside the emulator is the emulator itself. Reach the host at `10.0.2.2` (the standard AVD's host alias) for both: + +```bash +REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \ + EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \ + npm run android +``` + +- **`REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2`** — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with `Failed to connect to /:8081` before any JS loads. +- **`EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:`** — the client's daemon endpoint (`packages/app/src/runtime/host-runtime.ts`); when unset it defaults to `localhost:6767`, the production daemon. Use `$PASEO_SERVICE_DAEMON_PORT` for a worktree daemon running as a Paseo service, or `6768` for a standalone `npm run dev:server`. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (`npx expo start -c`) if a change doesn't take. + +**Alternative — `adb reverse` + `localhost`** (if `10.0.2.2` misbehaves): + +```bash +adb reverse tcp:8081 tcp:8081 +adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT +REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ + EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \ + npm run android +``` + +This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. + ## F-Droid / source-only Android builds F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild: diff --git a/docs/architecture.md b/docs/architecture.md index 480ad596e..a02245057 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,6 +138,8 @@ Electron wrapper for macOS, Linux, and Windows. > **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. > +> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs. +> > **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. > > **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages. diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 9c6da70ee..9e0405397 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -37,6 +37,17 @@ npm run build:main --workspace=@getpaseo/desktop PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop ``` +Run the shared browser profile fixture with: + +```bash +PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop +``` + +The browser profile group runs two Electron processes in sequence. It verifies that each +renderer-side `did-attach` identity maps to the correct main-process guest, that two live +tabs share cookies and local storage through one persistent session, and that the data is +still present after the first Electron process exits and the second starts. + The automation group uses a real guest webview to verify the page-side ref contract: ARIA-like snapshot text includes headings, static text, and controls; refs survive `pushState` when the element still matches; same-URL rerenders stale old refs; and a diff --git a/docs/design.md b/docs/design.md index 172416984..588b53904 100644 --- a/docs/design.md +++ b/docs/design.md @@ -131,6 +131,10 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop. +On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths. + +Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate. + A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR. --- diff --git a/docs/development.md b/docs/development.md index d435f11f0..443e8e5db 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,11 +59,42 @@ startup routing, remembered workspace restore, or active workspace selection. Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`. +**Prerequisites (macOS only).** The service shells out to the Apple toolchain, so beyond the `npm ci` that worktree setup runs you must install: + +- **Xcode** (the full app, not just the Command Line Tools) — install it from the Mac App Store, or from `developer.apple.com/download` for a specific version. It provides `xcodebuild` and `xcrun simctl`; accept its license and let first-run component installation finish before starting the service. +- **An iOS Simulator runtime with at least one iPhone device type**. Recent Xcode versions may not bundle a runtime — add one via Xcode → Settings → Components (older Xcode: "Platforms"). The service targets `iPhone 16 Pro` by default (override with `PASEO_IOS_DEVICE_TYPE`) and falls back to any iPhone; it fails with `No iPhone simulator device type is installed` when none exist. +- **Homebrew** — CocoaPods itself installs automatically: `expo prebuild` runs `pod install` on a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It tries `gem install cocoapods` first and falls back to Homebrew (`brew install cocoapods`), so having Homebrew available lets that fallback succeed without a manual step. + +`serve-sim`, Expo, and Metro come from `npm ci`, and CocoaPods installs itself on the first prebuild as described above. + The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port. Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time. -Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface. +Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive `/.sim` preview: a `serve-sim` stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at `basePath: "/.sim"` (`packages/app/metro.config.cjs`) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the `${PASEO_URL}/.sim` link the service prints — not `serve-sim`'s raw stream port (`:3100`), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below. + +**Troubleshooting.** If `xcrun simctl` fails with `unable to find utility "simctl"`, the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, then confirm with `xcrun --find simctl`. + +### Running the iOS app on a local simulator + +For fast, native, interactive iOS dev at the Mac — as opposed to the remote `/.sim` preview above — skip the service and build the dev client directly: + +```bash +npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.app +``` + +`expo run:ios` starts its own Metro and gives you the normal Simulator.app window (full speed, native touch, no stream). + +**Pointing the app at a daemon.** The client resolves its local daemon from `EXPO_PUBLIC_LOCAL_DAEMON` (`packages/app/src/runtime/host-runtime.ts`); when unset it falls back to `localhost:6767`, the production `~/.paseo` daemon. To target a worktree's dev daemon instead, set it on the build command: + +```bash +EXPO_PUBLIC_LOCAL_DAEMON=localhost:${PASEO_SERVICE_DAEMON_PORT} npm run ios # worktree daemon running as a Paseo service +EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone `npm run dev:server` +``` + +The iOS simulator shares the Mac's loopback, so `localhost:` reaches the host daemon directly. + +**Gotcha — `EXPO_PUBLIC_*` is inlined into the JS bundle at Metro bundle time, not read at runtime.** Set it in the same shell that starts Metro. If the app still connects to the old daemon, Metro served a cached bundle; re-bundle clean with `cd packages/app && EXPO_PUBLIC_LOCAL_DAEMON=… npx expo start -c` and reload the app. ### Desktop renderer profiling @@ -72,6 +103,17 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul It launches its own Electron-flavored Expo server and passes that URL to Electron. Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen +transition, and 751-pixel settings split with: + +```bash +npm run verify:electron-cdp --workspace=@getpaseo/desktop +``` + +The verifier reads the same `EXPO_PORT` and +`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when +testing an isolated instance on non-default ports. + When running a dedicated Electron QA instance against a non-default Expo port, set `EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so `PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081. diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 46c385d20..28ee87f1d 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -75,6 +75,9 @@ definition, no longer eligible to begin. so its injected `collapsable={false}` reaches Android/Fabric. - Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles in sidebar components. +- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions: + closing it or yielding its width marks it inactive and applies `display: none` without conditionally + removing the sidebar tree. - Animated panel nodes use React Native static styles plus inline theme values. Do not attach Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node has caused native crashes. diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 86414257a..020ee0ed4 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -37,6 +37,14 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. +## Durable item anchors + +Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows. + +Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. + +The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context. + ## Resume behavior When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run. diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/assistant-fork-menu.spec.ts index 47a26f531..965c87b94 100644 --- a/packages/app/e2e/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/assistant-fork-menu.spec.ts @@ -54,6 +54,28 @@ async function expectChatHistoryPill(page: Page): Promise { test.describe("Assistant fork menu", () => { test.describe.configure({ timeout: 180_000 }); + test("forks a failed assistant turn that has no provider message id", async ({ + page, + seedForkWorkspace, + }) => { + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-failed-turn-", + title: "Assistant fork failed turn", + model: "ten-second-stream", + }); + + await openAgentRoute(page, session); + await expectComposerVisible(page); + await submitMessage(page, "Emit a synthetic turn failure."); + await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({ + timeout: 30_000, + }); + + await openAssistantForkMenu(page); + await page.getByTestId("assistant-fork-menu-new-tab").click(); + await expectChatHistoryPill(page); + }); + test("focuses a forked assistant turn in a new workspace draft tab", async ({ page, seedForkWorkspace, diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index 63a6e0cec..c6d0fb3ce 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -61,13 +61,14 @@ export async function openMobileAgentSidebar(page: Page): Promise { export async function closeMobileAgentSidebar(page: Page): Promise { const closeButton = page.getByTestId("sidebar-close"); - await expect(closeButton).toBeInViewport({ timeout: 5_000 }); - await closeButton.click({ force: true }); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); } -// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. +// The mobile sidebar panel animates via translateX. Waiting for its header to be fully visible +// prevents a close click from targeting a button while the panel is still moving. export async function expectMobileAgentSidebarVisible(page: Page): Promise { - await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 }); + await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1, timeout: 5_000 }); } export async function expectMobileAgentSidebarHidden(page: Page): Promise { diff --git a/packages/app/e2e/provider-subagents.real.spec.ts b/packages/app/e2e/provider-subagents.real.spec.ts index 62c08694e..a4bf02fad 100644 --- a/packages/app/e2e/provider-subagents.real.spec.ts +++ b/packages/app/e2e/provider-subagents.real.spec.ts @@ -14,6 +14,7 @@ import { openSubagentsTrack } from "./helpers/subagents"; interface ProviderSubagentCase { provider: RewindFlowProvider; sentinel: string; + expectedName: string; prompt: string; providerConfig?: Parameters[0]["providerConfig"]; } @@ -22,20 +23,23 @@ const cases: ProviderSubagentCase[] = [ { provider: "claude", sentinel: "CLAUDE_CHILD_SENTINEL", + expectedName: "sentinel_child", providerConfig: { model: "opus" }, prompt: - "Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + 'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "codex", sentinel: "CODEX_CHILD_SENTINEL", + expectedName: "Sentinel child", providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } }, prompt: - 'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.', + 'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "opencode", sentinel: "OPENCODE_CHILD_SENTINEL", + expectedName: "Explore", prompt: "Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", }, @@ -66,6 +70,7 @@ test.describe("real provider subagent timelines", () => { const rows = page.locator('[data-testid^="subagents-track-row-"]'); await expect(rows).toHaveCount(1, { timeout: 60_000 }); + await expect(rows.first()).toContainText(scenario.expectedName); await rows.first().click(); const panel = page.getByTestId("provider-subagent-panel"); @@ -76,6 +81,15 @@ test.describe("real provider subagent timelines", () => { await expect( panel.getByText("Start chatting with this agent...", { exact: true }), ).toHaveCount(0); + + await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click(); + await expect( + page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(), + ).toBeVisible({ timeout: 60_000 }); + const archiveFinished = page.getByTestId("subagents-track-archive-finished"); + await expect(archiveFinished).toBeVisible({ timeout: 30_000 }); + await archiveFinished.click(); + await expect(rows).toHaveCount(0, { timeout: 30_000 }); } finally { await cleanupRewindFlow({ handle, cwd }); } diff --git a/packages/app/e2e/sidebar-help.spec.ts b/packages/app/e2e/sidebar-help.spec.ts index 9f95ed1ca..abf6919f1 100644 --- a/packages/app/e2e/sidebar-help.spec.ts +++ b/packages/app/e2e/sidebar-help.spec.ts @@ -6,6 +6,7 @@ const DISCORD_DESTINATION = /^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/; const GITHUB_ISSUE_DESTINATION = /^https:\/\/github\.com\/(?:getpaseo\/paseo\/issues\/new(?:\/choose)?(?:[/?#]|$)|login\?return_to=https%3A%2F%2Fgithub\.com%2Fgetpaseo%2Fpaseo%2Fissues%2Fnew$)/; +const CHANGELOG_DESTINATION = /^https:\/\/paseo\.sh\/changelog(?:[/?#]|$)/; const APP_VERSION = /^Paseo v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; async function openHelpMenu(page: Page): Promise { @@ -52,8 +53,9 @@ test("opens troubleshooting tools from the sidebar help menu", async ({ page }) return { y, height }; }); expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y); - await expect(page.getByText("Troubleshoot", { exact: true })).toBeVisible(); + await expect(page.getByText("Help", { exact: true })).toBeVisible(); await expect(page.getByText("Report an issue", { exact: true })).toBeVisible(); + await expect(page.getByText("What's new", { exact: true })).toBeVisible(); await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION); await page.getByTestId("sidebar-help-diagnostics").click(); @@ -66,7 +68,7 @@ test("opens troubleshooting tools from the sidebar help menu", async ({ page }) await closeSheet(page, "keyboard-shortcuts-dialog"); }); -test("opens the preferred issue-reporting destinations", async ({ page }) => { +test("opens support and release destinations", async ({ page }) => { await gotoAppShell(page); await openHelpMenu(page); @@ -74,6 +76,9 @@ test("opens the preferred issue-reporting destinations", async ({ page }) => { await openHelpMenu(page); await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION); + + await openHelpMenu(page); + await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION); }); test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => { diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index ed6940203..d23085852 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -165,3 +165,132 @@ test.describe("Mobile sidebar panelState transition", () => { await expectMobileAgentSidebarHidden(page); }); }); + +test.describe("Half-screen desktop layout", () => { + test.use({ viewport: { width: 751, height: 982 } }); + + test("keeps the sidebar scroll position across close and reopen", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-scroll-" }); + + try { + let lastWorkspaceId = workspace.workspaceId; + for (let index = 0; index < 24; index += 1) { + const created = await workspace.client.createWorkspace({ + source: { + kind: "directory", + path: workspace.repoPath, + projectId: workspace.projectId, + }, + title: `Retained sidebar ${index + 1}`, + }); + if (!created.workspace) { + throw new Error(created.error ?? "Failed to fill the retained sidebar"); + } + lastWorkspaceId = created.workspace.id; + } + + await gotoAppShell(page); + await waitForSidebarWorkspace(page, lastWorkspaceId); + + const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); + const scrollTop = await sidebarScroll.evaluate((element) => { + element.scrollTop = 160; + return element.scrollTop; + }); + expect(scrollTop).toBe(160); + + await page.getByTestId("menu-button").click(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + + await page.getByTestId("menu-button").click(); + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => { + await gotoAppShell(page); + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); + }); + + test("keeps the left toggle center-owned without left window controls", async ({ page }) => { + await gotoAppShell(page); + + const openToggle = page.getByTestId("menu-button"); + const openBounds = await openToggle.locator("svg").first().boundingBox(); + expect(openBounds).not.toBeNull(); + expect(openBounds?.x).toBeGreaterThan(12); + + await openToggle.click(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + + const closedToggle = page.getByTestId("menu-button"); + const closedBounds = await closedToggle.locator("svg").first().boundingBox(); + expect(closedBounds).not.toBeNull(); + expect(closedBounds?.x).toBeCloseTo(12, 0); + expect(closedBounds?.y).toBe(openBounds?.y); + }); + + test("yields app navigation to the settings split", async ({ page }) => { + await gotoAppShell(page); + await page.getByTestId("sidebar-settings").click(); + + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + await expect(page.getByTestId("settings-detail-pane")).toBeVisible(); + await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); + }); + + test("yields app navigation to the Explorer", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + + await page.getByTestId("workspace-explorer-toggle").first().click(); + await expect( + page.getByTestId("explorer-tab-files").filter({ visible: true }).first(), + ).toBeVisible(); + await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible(); + await expect(page.getByTestId("explorer-close")).toBeVisible(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + + const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox(); + const headerGlyphBounds = await page + .getByTestId("menu-button") + .locator("svg") + .first() + .boundingBox(); + const tabGlyphBounds = await page + .locator('[data-testid^="workspace-tab-"]') + .first() + .locator("svg") + .first() + .boundingBox(); + expect(centerBounds).not.toBeNull(); + expect(headerGlyphBounds).not.toBeNull(); + expect(tabGlyphBounds).not.toBeNull(); + expect((headerGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0)).toBeCloseTo( + (tabGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0), + 0, + ); + + await expect + .poll( + async () => + (await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0, + ) + .toBeGreaterThanOrEqual(400); + + await page.getByTestId("explorer-close").click(); + await expect(page.getByTestId("explorer-tab-files")).not.toBeVisible(); + await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible(); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 5632c9129..0f9f04b8c 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -33,6 +33,24 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ }); const HISTORY_START_THRESHOLD_PX = 96; +interface HistoryRowDisplayVariants { + regular?: StreamItem; + compact?: StreamItem; +} + +const historyRowDisplayVariants = new WeakMap(); + +function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem { + let variants = historyRowDisplayVariants.get(item); + if (!variants) { + variants = {}; + historyRowDisplayVariants.set(item, variants); + } + const key = compact ? "compact" : "regular"; + variants[key] ??= { ...item }; + return variants[key]; +} + function keyExtractor(item: { id: string }): string { return item.id; } @@ -41,6 +59,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const { agentId, segments, + historyRowRevision, + liveHeadRowRevision, boundary, renderers, listEmptyComponent, @@ -73,12 +93,33 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); - const historyRows = useMemo(() => { + const historyItems = useMemo(() => { if (segments.historyVirtualized.length === 0) { return segments.historyMounted; } return [...segments.historyVirtualized, ...segments.historyMounted]; }, [segments.historyMounted, segments.historyVirtualized]); + // Keep unchanged item identities intact so live updates only rerender rows + // whose projected content or local display state actually changed. A rare + // breakpoint change intentionally refreshes the whole history window. + const globallyRevisedHistoryRows = useMemo(() => { + const globalDisplayState = historyRowRevision?.globalDisplayState ?? false; + return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)); + }, [historyItems, historyRowRevision?.globalDisplayState]); + const displayStateHistoryRows = useMemo( + () => + globallyRevisedHistoryRows.map((item) => + historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item, + ), + [globallyRevisedHistoryRows, historyRowRevision?.displayStateById], + ); + const historyRows = useMemo( + () => + displayStateHistoryRows.map((item) => + historyRowRevision?.contentById.has(item.id) ? { ...item } : item, + ), + [displayStateHistoryRows, historyRowRevision?.contentById], + ); const clearNativeViewportSettling = useCallback(() => { if (nativeViewportSettlingFrameIdRef.current !== null) { @@ -307,12 +348,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const renderItem = useStableEvent( ({ item, index }: ListRenderItemInfo): ReactElement | null => { - const rendered = renderHistoryMountedRow(item, index, historyRows); + const rendered = renderHistoryMountedRow(item, index, historyItems); return (rendered ?? null) as ReactElement | null; }, ); const liveHeaderContent = useMemo(() => { + // Stable render events read the latest expansion state; this revision makes + // the memo invoke them again when that state changes. + void liveHeadRowRevision; const liveHeadRows = segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); @@ -331,7 +375,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat {liveAuxiliary} ); - }, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]); + }, [ + boundary, + listEmptyComponent, + liveHeadRowRevision, + renderLiveAuxiliary, + renderLiveHeadRow, + segments.liveHead, + ]); const historyFooterContent = useMemo(() => { if (!isLoadingOlderHistory) { @@ -344,12 +395,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ); }, [isLoadingOlderHistory]); + // RN's FlatList strictMode keeps its internal renderItem wrapper stable when + // data or the live header changes, preserving the row identities above. return ( { @@ -25,8 +25,6 @@ vi.hoisted(() => { }); }); -vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null })); - function userMessage(index: number): StreamItem { return { kind: "user_message", @@ -148,6 +146,59 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("rerenders a stable live-head row when its revision changes", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + const liveHead = [userMessage(1)]; + let label = "collapsed"; + const renderLiveHeadRow = vi.fn(() =>
{label}
); + const renderInput: StreamRenderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [], + liveHead, + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: false, + hasLiveHead: true, + }, + renderers: { + ...createRenderers(vi.fn()), + renderLiveHeadRow, + }, + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 0 })); + }); + expect(container.textContent).toContain("collapsed"); + + label = "expanded"; + act(() => { + root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 1 })); + }); + + expect(container.textContent).toContain("expanded"); + expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); + }); + it("fires near-history-start when the user scrolls near the top", async () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 60a0ad961..bc5c11604 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -25,7 +25,6 @@ const USER_SCROLL_DELTA_EPSILON = 1; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const HISTORY_START_THRESHOLD_PX = 96; -import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; const historyStartSlotStyle: CSSProperties = { display: "flex", @@ -95,6 +94,7 @@ function isScrollContainerOverscrolledPastBottom( function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments, + liveHeadRowRevision, boundary, renderers, listEmptyComponent, @@ -131,11 +131,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollTimeoutRef = useRef(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); - const showDesktopWebScrollbar = !isMobileBreakpoint; - const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { - enabled: showDesktopWebScrollbar, - contentRef, - }); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -540,10 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { + void liveHeadRowRevision; return segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); - }, [renderLiveHeadRow, segments.liveHead]); + }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); const liveAuxiliary = useMemo(() => { return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); @@ -564,47 +560,40 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !liveAuxiliary; return ( - <> -
-
- {historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { - const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } - return ( -
- {renderHistoryVirtualizedRow( - item, - virtualRow.index, - segments.historyVirtualized, - )} -
- ); - })} -
- ) : null} - {mountedHistoryRows} - {liveHeadRows} - {liveAuxiliary} - {shouldRenderEmpty ? listEmptyComponent : null} -
+
+
+ {historyStartSlot} + {shouldUseVirtualizer ? ( +
+ {virtualRows.map((virtualRow) => { + const item = segments.historyVirtualized[virtualRow.index]; + if (!item) { + return null; + } + return ( +
+ {renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)} +
+ ); + })} +
+ ) : null} + {mountedHistoryRows} + {liveHeadRows} + {liveAuxiliary} + {shouldRenderEmpty ? listEmptyComponent : null}
- {scrollbarOverlay} - +
); } diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index f8c272d28..dd9a798ed 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -51,9 +51,17 @@ export interface StreamSegmentRenderers { renderLiveAuxiliary: () => ReactNode; } +export interface StreamHistoryRowRevision { + contentById: { has(id: string): boolean }; + displayStateById: { has(id: string): boolean }; + globalDisplayState: boolean; +} + export interface StreamRenderInput { agentId: string; segments: StreamRenderSegments; + historyRowRevision?: StreamHistoryRowRevision; + liveHeadRowRevision?: unknown; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts index c18fad104..33e56901d 100644 --- a/packages/app/src/agent-stream/turn-boundary.test.ts +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { + resolveAssistantTurnBoundaryMessageId, + resolveAssistantTurnForkBoundary, +} from "./turn-boundary"; function timestamp(seed: number): Date { return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); @@ -62,3 +65,65 @@ describe("resolveAssistantTurnBoundaryMessageId", () => { ).toBeUndefined(); }); }); + +describe("resolveAssistantTurnForkBoundary", () => { + it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => { + const failedTurn = { + ...assistantMessage("assistant-error", 2), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [userMessage("user-1", 1), failedTurn], + startIndex: 1, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + }); + }); + + it("includes the provider message id with a supported timeline cursor", () => { + const selected = { + ...assistantMessage("assistant-1", 2, "msg-assistant-1"), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + boundaryMessageId: "msg-assistant-1", + }); + }); + + it("falls back to the provider message id when timeline cursors are unsupported", () => { + const selected = { + ...assistantMessage("assistant-1", 2, "msg-assistant-1"), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toEqual({ boundaryMessageId: "msg-assistant-1" }); + }); + + it("does not offer an unavailable boundary", () => { + expect( + resolveAssistantTurnForkBoundary({ + items: [assistantMessage("assistant-1", 2)], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts index 75fa63a3e..637c83b72 100644 --- a/packages/app/src/agent-stream/turn-boundary.ts +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -1,4 +1,8 @@ -import type { StreamItem } from "@/types/stream"; +import type { StreamItem, TimelinePosition } from "@/types/stream"; + +export type AssistantTurnForkBoundary = + | { boundaryCursor: TimelinePosition; boundaryMessageId?: string } + | { boundaryCursor?: undefined; boundaryMessageId: string }; export function resolveAssistantTurnBoundaryMessageId(input: { items: readonly StreamItem[]; @@ -11,3 +15,21 @@ export function resolveAssistantTurnBoundaryMessageId(input: { // Forking without the selected assistant's durable message id would send the wrong slice. return item.messageId || undefined; } + +export function resolveAssistantTurnForkBoundary(input: { + items: readonly StreamItem[]; + startIndex: number; + supportsTimelineCursor: boolean; +}): AssistantTurnForkBoundary | undefined { + const item = input.items[input.startIndex]; + if (item?.kind !== "assistant_message") { + return undefined; + } + if (input.supportsTimelineCursor && item.timelineCursor) { + return { + boundaryCursor: item.timelineCursor, + ...(item.messageId ? { boundaryMessageId: item.messageId } : {}), + }; + } + return item.messageId ? { boundaryMessageId: item.messageId } : undefined; +} diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 2f7bf3c8b..5b7e01e8d 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -9,7 +9,7 @@ import { collectAssistantTurnContentForStreamRenderStrategy, type StreamStrategy, } from "./strategy"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary"; import { AssistantTurnFooter, LiveElapsed, @@ -31,7 +31,7 @@ const workingIndicatorColorMapping = (theme: Theme) => ({ export type TurnContentStrategy = StreamStrategy; export type AssistantTurnForkHandler = (input: { target: AssistantForkTarget; - boundaryMessageId?: string; + boundary: AssistantTurnForkBoundary; }) => Promise | void; export const TurnFooter = memo(function TurnFooter({ @@ -39,12 +39,14 @@ export const TurnFooter = memo(function TurnFooter({ inFlightTurnStartedAt, host, strategy, + supportsTimelineCursor, onForkAssistantTurn, }: { isRunning: boolean; inFlightTurnStartedAt: Date | null; host: TurnFooterHost | null; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { if (isRunning) { @@ -63,6 +65,7 @@ export const TurnFooter = memo(function TurnFooter({ items={host.items} timing={host.timing} startIndex={host.startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> ); @@ -73,12 +76,14 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { return ( @@ -88,6 +93,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items={items} timing={timing} startIndex={startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> @@ -130,12 +136,14 @@ function CompletedTurnFooter({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { const getContent = useCallback( @@ -147,18 +155,27 @@ function CompletedTurnFooter({ }), [strategy, items, startIndex], ); - const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({ + const boundary = resolveAssistantTurnForkBoundary({ items, startIndex, + supportsTimelineCursor, }); + const handleFork = useCallback( + (target: AssistantForkTarget) => { + if (!boundary) { + return; + } + return onForkAssistantTurn?.({ target, boundary }); + }, + [boundary, onForkAssistantTurn], + ); return ( ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 8250461f1..a1354787f 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -57,8 +57,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { ToolCallGroup } from "@/components/tool-call-group"; -import { compactToolCallRuns } from "@/tool-calls/grouping"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, +} from "@/tool-calls/detail-level/projection"; +import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; @@ -139,6 +142,7 @@ function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }): ReactNode { if (!input.content) { @@ -152,6 +156,7 @@ function renderStreamItemWithTurnFooter(input: { items={footerHost.items} timing={footerHost.timing} startIndex={footerHost.startIndex} + supportsTimelineCursor={input.supportsTimelineCursor} onForkAssistantTurn={input.onForkAssistantTurn} /> ) : null; @@ -259,6 +264,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; +const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; function buildChatHistoryAttachment(input: { draftId: string; @@ -278,6 +284,7 @@ function buildChatHistoryAttachment(input: { serverId: input.serverId, agentId: input.agentId, boundaryMessageId: input.payload.boundaryMessageId, + boundaryCursor: input.payload.boundaryCursor, itemCount: input.payload.itemCount, }, }; @@ -365,6 +372,10 @@ const AgentStreamViewComponent = forwardRef + state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, + ); const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -462,7 +473,7 @@ const AgentStreamViewComponent = forwardRef { + async ({ target, boundary }) => { try { if (!supportsAgentForkContext) { toast?.error(t("message.actions.forkUnavailable")); @@ -474,10 +485,7 @@ const AgentStreamViewComponent = forwardRef { const draftId = generateDraftId(); - const payload = await client.buildAgentForkContext( - agentId, - boundaryMessageId ? { boundaryMessageId } : {}, - ); + const payload = await client.buildAgentForkContext(agentId, boundary); const attachment = buildChatHistoryAttachment({ draftId, serverId: resolvedServerId, @@ -544,25 +552,38 @@ const AgentStreamViewComponent = forwardRef prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems), + [effectiveStreamItems, toolCallDetailLevel], + ); + const projectedToolCalls = useMemo( () => - compactToolCallRuns({ + projectToolCallDetailLevel({ + level: toolCallDetailLevel, tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, - enabled: toolCallDetailLevel !== "detailed", + preparedHistory: preparedToolCallHistory, + isTurnActive: context.status === "running", }), - [effectiveStreamHead, effectiveStreamItems, toolCallDetailLevel], + [ + context.status, + effectiveStreamHead, + effectiveStreamItems, + preparedToolCallHistory, + toolCallDetailLevel, + ], ); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ agentStatus: context.status, - tail: compactedToolCalls.tail, - head: compactedToolCalls.head, + tail: projectedToolCalls.tail, + head: projectedToolCalls.head, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [context.status, isMobile, compactedToolCalls.head, compactedToolCalls.tail]); + }, [context.status, isMobile, projectedToolCalls.head, projectedToolCalls.tail]); const streamLayout = useMemo( () => layoutStream({ @@ -691,7 +712,11 @@ const AgentStreamViewComponent = forwardRef, isLastInSequence: boolean) => { + ( + item: Extract, + isLastInSequence: boolean, + maxDetailHeight?: number, + ) => { const { payload } = item; if (payload.source === "agent") { @@ -720,6 +745,7 @@ const AgentStreamViewComponent = forwardRef ); } @@ -735,6 +761,7 @@ const AgentStreamViewComponent = forwardRef ); }, @@ -743,32 +770,37 @@ const AgentStreamViewComponent = forwardRef) => { - const group = compactedToolCalls.groupsByHostId.get(item.id); + const group = projectedToolCalls.groupsByHostId.get(item.id); if (!group) { return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } - const expanded = expandedToolCallGroupIds.has(group.id); + const expanded = expandedToolCallGroupIds.has(group.run.id); return ( - - {group.calls.map((call, index) => ( - - {renderSingleToolCallItem(call, index === group.calls.length - 1)} - - ))} - + {expanded + ? group.run.calls.map((call, index) => ( + + {renderSingleToolCallItem( + call, + index === group.run.calls.length - 1, + GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + )} + + )) + : null} + ); }, [ - compactedToolCalls.groupsByHostId, + projectedToolCalls.groupsByHostId, expandedToolCallGroupIds, renderSingleToolCallItem, setToolCallGroupExpanded, - toolCallDetailLevel, ], ); @@ -826,10 +858,17 @@ const AgentStreamViewComponent = forwardRef ) : null, @@ -864,6 +904,7 @@ const AgentStreamViewComponent = forwardRef(() => { @@ -960,6 +1001,14 @@ const AgentStreamViewComponent = forwardRef ({ + contentById: projectedToolCalls.historyGroupUpdatesByHostId, + displayStateById: expandedToolCallGroupIds, + globalDisplayState: isMobile, + }), + [expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId], + ); return ( @@ -968,6 +1017,8 @@ const AgentStreamViewComponent = forwardRef state.closeDesktopFileExplorer); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); + const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const explorerWidth = usePanelStore((state) => state.explorerWidth); + const { width: viewportWidth } = useWindowDimensions(); const cycleTheme = useCallback(() => { const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); @@ -453,22 +477,52 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon useActiveWorktreeNewAction(); useGlobalNewWorkspaceAction(); + const appContentMinimumWidth = resolveDesktopAppContentMinimum({ + isSettingsRoute: pathname.includes("/settings"), + isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen, + requestedExplorerWidth: explorerWidth, + viewportWidth, + }); + const desktopSidebarMounted = chromeEnabled && !isFocusModeEnabled; + const desktopSidebarVisible = + !isCompactLayout && + desktopSidebarMounted && + isDesktopAgentListOpen && + canDesktopAppSidebarShare({ + contentMinimumWidth: appContentMinimumWidth, + requestedSidebarWidth: sidebarWidth, + viewportWidth, + }); + const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); + const appChromeLayout = resolveDesktopAppChromeLayout({ + desktopSidebarRendered: desktopSidebarVisible, + hasTopLeftWindowControls, + sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled, + }); const sidebarChrome = ( ); - const workspaceChrome = ( - {!isCompactLayout ? sidebarChrome : null} + {!isCompactLayout ? ( + + {sidebarChrome} + + ) : null} {isCompactLayout && chromeEnabled ? ( - {children} + + {children} + ) : ( - {children} + + {children} + )} ); @@ -476,6 +530,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const surface = ( {workspaceChrome} + {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( + + + + + + ) : null} {isCompactLayout ? sidebarChrome : null} @@ -503,19 +569,22 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon } function SidebarChrome({ - showSidebar, + mounted, + visible, keyboardShortcutsEnabled, }: { - showSidebar: boolean; + mounted: boolean; + visible: boolean; keyboardShortcutsEnabled: boolean; }) { const isCompactLayout = useIsCompactFormFactor(); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isCompactLayout }), ); + const active = visible && isOpen; return ( - - {showSidebar ? : null} + + {mounted ? : null} ); @@ -862,13 +931,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) { function RootProviders({ children }: { children: ReactNode }) { return ( - - - - {children} - - - + + + + + {children} + + + + ); } @@ -888,6 +959,16 @@ function RootAppTree() { } export default function RootLayout() { + useEffect(() => installWebScrollbarStyles(), []); + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextState) => { + if (nextState !== "active") { + void flushDraftPersistStorage(); + } + }); + return () => subscription.remove(); + }, []); + return ( @@ -904,4 +985,15 @@ const layoutStyles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.surface0, }, + windowSidebarToggle: { + position: "absolute", + top: 0, + left: 0, + zIndex: 20, + height: HEADER_INNER_HEIGHT, + flexDirection: "row", + alignItems: "center", + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: "transparent", + }, })); diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 82e447f92..2199ce29f 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment { serverId: string; agentId: string; boundaryMessageId?: string | null; + boundaryCursor?: { epoch: string; seq: number } | null; itemCount?: number; }; } diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index 6c50ce17c..319f247d2 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -34,6 +34,10 @@ describe("fileUriToPath", () => { it("converts Windows drive-letter file URIs back to paths", () => { expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt"); }); + + it("converts host-based file URIs back to UNC paths", () => { + expect(fileUriToPath("file://server/share/shot%231.png")).toBe("\\\\server\\share\\shot#1.png"); + }); }); describe("localFileSourceToPath", () => { diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index e1422a7f4..bb1903f9f 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -162,7 +162,11 @@ export function fileUriToPath(uri: string): string { if (!uri.startsWith("file://")) { return uri; } - const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, "")); + const fileSource = uri.slice("file://".length); + const decodedPath = decodeFilePathSource(fileSource); + if (!fileSource.startsWith("/")) { + return `\\\\${decodedPath.replace(/\//g, "\\")}`; + } return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } diff --git a/packages/app/src/browser-automation/handler.test.ts b/packages/app/src/browser-automation/handler.test.ts index a40fb7699..6a7d3573a 100644 --- a/packages/app/src/browser-automation/handler.test.ts +++ b/packages/app/src/browser-automation/handler.test.ts @@ -73,10 +73,7 @@ class FakeDaemonClient { class FakeBrowserBridge { public readonly executedRequests: BrowserAutomationExecuteRequest[] = []; - public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> = - []; public readonly unregisteredWorkspaceBrowsers: string[] = []; - public readonly clearedPartitions: string[] = []; public readonly activeWorkspaceBrowsers: Array<{ browserId: string | null; workspaceId: string; @@ -94,21 +91,10 @@ class FakeBrowserBridge { return this.response ?? currentListTabsPayload(request.requestId); }; - public registerWorkspaceBrowser = async (input: { - browserId: string; - workspaceId: string; - }): Promise => { - this.registeredWorkspaceBrowsers.push(input); - }; - public unregisterWorkspaceBrowser = async (browserId: string): Promise => { this.unregisteredWorkspaceBrowsers.push(browserId); }; - public clearPartition = async (browserId: string): Promise => { - this.clearedPartitions.push(browserId); - }; - public setWorkspaceActiveBrowser = async (input: { browserId: string | null; workspaceId: string; @@ -118,9 +104,17 @@ class FakeBrowserBridge { } class FakeResidentBrowser { - public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = []; + public readonly ensuredWebviews: Array<{ + browserId: string; + workspaceId: string; + url: string; + }> = []; - public ensure = (input: { browserId: string; url: string }): HTMLElement | null => { + public ensure = (input: { + browserId: string; + workspaceId: string; + url: string; + }): HTMLElement | null => { this.ensuredWebviews.push(input); return null; }; @@ -321,12 +315,13 @@ describe("mountBrowserAutomationHandler", () => { }), ); expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId); - expect(browser.browser.registeredWorkspaceBrowsers).toEqual([ - { browserId: result.browserId, workspaceId: "wks_workspace_a" }, - ]); expect(browser.browser.activeWorkspaceBrowsers).toEqual([]); expect(browser.resident.ensuredWebviews).toEqual([ - { browserId: result.browserId, url: "https://example.com" }, + { + browserId: result.browserId, + workspaceId: "wks_workspace_a", + url: "https://example.com", + }, ]); expect(browser.browser.executedRequests).toEqual([ { @@ -366,7 +361,10 @@ describe("mountBrowserAutomationHandler", () => { }, ]); expect(browser.resident.ensuredWebviews).toEqual([ - expect.objectContaining({ url: "https://example.com" }), + expect.objectContaining({ + workspaceId: "wks_workspace_a", + url: "https://example.com", + }), ]); }); @@ -440,7 +438,7 @@ describe("mountBrowserAutomationHandler", () => { }); }); - test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => { + test("browser_close_tab removes the workspace tab, browser record, resident webview, and registry entry", async () => { const browser = new BrowserAutomationHandlerHarness(); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId: "server-1", @@ -466,7 +464,6 @@ describe("mountBrowserAutomationHandler", () => { expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]); expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined(); expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]); - expect(browser.browser.clearedPartitions).toEqual([result.browserId]); expect(currentBrowserTabs()).toEqual([]); }); diff --git a/packages/app/src/browser-automation/handler.ts b/packages/app/src/browser-automation/handler.ts index d728248ea..6f8ab8ad8 100644 --- a/packages/app/src/browser-automation/handler.ts +++ b/packages/app/src/browser-automation/handler.ts @@ -258,7 +258,6 @@ async function closeBrowserTabForRequest(params: { useBrowserStore.getState().removeBrowser(browserId); removeResidentBrowserWebview(browserId); await browserHost?.unregisterWorkspaceBrowser?.(browserId); - await browserHost?.clearPartition?.(browserId); return { requestId: request.requestId, @@ -337,10 +336,8 @@ async function openBrowserTabForRequest(params: { browserId, }); - await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId }); - if (browserHost?.executeAutomationCommand) { - ensureResidentBrowserWebview({ browserId, url: normalizedUrl }); + ensureResidentBrowserWebview({ browserId, workspaceId, url: normalizedUrl }); const registered = await waitForBrowserRegistration({ request, browserId, diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 57e424cba..68814e579 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -22,7 +22,6 @@ import { import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import { createControlGeometry } from "@/components/ui/control-geometry"; import { isNative, isWeb } from "@/constants/platform"; -import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useSafeAreaInsets } from "react-native-safe-area-context"; // Horizontal indent token shared by the sheet header (title, back arrow, @@ -460,11 +459,6 @@ export interface AdaptiveModalSheetProps { desktopMaxWidth?: number; scrollable?: boolean; presentation?: "push" | "replace"; - /** - * Render the themed desktop-web scrollbar over the scroll area instead of the - * native browser scrollbar. No-op on native and on the mobile bottom sheet. - */ - webScrollbar?: boolean; } export function AdaptiveModalSheet({ @@ -479,16 +473,11 @@ export function AdaptiveModalSheet({ desktopMaxWidth, scrollable = true, presentation, - webScrollbar = false, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); - const desktopScrollRef = useRef(null); - const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, { - enabled: webScrollbar && !isMobile, - }); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const compactSafeAreaPadding = useMemo( () => @@ -651,19 +640,13 @@ export function AdaptiveModalSheet({ {scrollable ? ( {children} - {desktopScrollbar.overlay} ) : ( {children} diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index b44e76928..473278229 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { isWeb } from "@/constants/platform"; +import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; interface AttachmentLightboxProps { metadata: AttachmentMetadata | null; @@ -38,15 +39,18 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp }; }, [metadata, onClose]); - const closeButtonStyle = useMemo( + const closeButtonRowStyle = useMemo( () => [ - styles.closeButton, + styles.closeButtonRow, { top: insets.top + theme.spacing[3], - right: insets.right + theme.spacing[3], }, ], - [insets.top, insets.right, theme.spacing], + [insets.top, theme.spacing], + ); + const closeButtonStyle = useMemo( + () => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }], + [insets.right, theme.spacing], ); const handleImageError = useCallback(() => setErrored(true), []); @@ -61,42 +65,46 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp return ( - - - - - {hasError ? ( - {t("message.attachments.imageLoadFailed")} - ) : ( - - - - )} - + + - - + style={styles.backdrop} + /> + + + {hasError ? ( + {t("message.attachments.imageLoadFailed")} + ) : ( + + + + )} + + + + + + + - + ); } @@ -129,6 +137,13 @@ const styles = StyleSheet.create((theme) => ({ bottom: 0, pointerEvents: "box-none", }, + closeButtonRow: { + position: "absolute", + left: 0, + right: 0, + alignItems: "flex-end", + pointerEvents: "box-none", + }, imageArea: { flex: 1, alignItems: "center", @@ -148,7 +163,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, closeButton: { - position: "absolute", width: 32, height: 32, borderRadius: 16, diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx index 95485c043..f3b9141aa 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -740,10 +740,10 @@ export function BrowserPane({ const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null; const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview); webviewRef.current = webview; - void getDesktopHost()?.browser?.registerWorkspaceBrowser?.({ browserId, workspaceId }); if (!residentWebview) { prepareBrowserWebview(webview, { browserId, + workspaceId, initialUrl: initialUnsafeNavigationMessage ? "about:blank" : initialUrlRef.current, }); } diff --git a/packages/app/src/components/browser-webview-resident.browser.test.ts b/packages/app/src/components/browser-webview-resident.browser.test.ts index b37c99491..c4f4de663 100644 --- a/packages/app/src/components/browser-webview-resident.browser.test.ts +++ b/packages/app/src/components/browser-webview-resident.browser.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + type BrowserWebviewProfileHost, clearResidentBrowserWebviewsForTests, ensureResidentBrowserWebview, getResidentBrowserWebview, @@ -14,6 +15,32 @@ import { } from "../utils/command-center-focus-restore"; const RESIDENT_HOST_ID = "paseo-browser-resident-webviews"; +const attachedBrowsers: Array<{ + browserId: string; + workspaceId: string; + webContentsId: number; +}> = []; +const profileHost: BrowserWebviewProfileHost = { + profilePartition: "persist:paseo-browser", + registerAttachedBrowser: async (input) => { + attachedBrowsers.push(input); + }, +}; + +function ensureTestBrowser(input: { + browserId: string; + workspaceId: string; + url: string; +}): HTMLElement | null { + return ensureResidentBrowserWebview({ ...input, profileHost }); +} + +function prepareTestBrowser( + webview: HTMLElement, + input: { browserId: string; workspaceId: string; initialUrl?: string | null }, +): void { + prepareBrowserWebview(webview, { ...input, profileHost }); +} function residentHost(): HTMLElement { const host = document.getElementById(RESIDENT_HOST_ID); @@ -49,6 +76,10 @@ function expectResidentWebviewParking(webview: HTMLElement): void { } describe("resident browser webviews", () => { + beforeEach(() => { + attachedBrowsers.length = 0; + }); + afterEach(() => { clearResidentBrowserWebviewsForTests(); }); @@ -70,15 +101,16 @@ describe("resident browser webviews", () => { }); it("creates a resident webview for an agent-created unfocused tab", () => { - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-agent", + workspaceId: "workspace-agent", url: "https://example.com", }); expect(webview).not.toBeNull(); expect(webview?.isConnected).toBe(true); expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent"); - expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent"); + expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser"); expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain( "https://example.com", ); @@ -86,6 +118,34 @@ describe("resident browser webviews", () => { expectResidentWebviewParking(webview as HTMLElement); }); + it("shares one profile and registers attached guests with explicit identity", () => { + const firstWebview = ensureTestBrowser({ + browserId: "browser-first", + workspaceId: "workspace-a", + url: "https://example.com/first", + }); + const secondWebview = ensureTestBrowser({ + browserId: "browser-second", + workspaceId: "workspace-b", + url: "https://example.com/second", + }); + if (!firstWebview || !secondWebview) { + throw new Error("Expected resident webviews"); + } + Object.assign(firstWebview, { getWebContentsId: () => 101 }); + Object.assign(secondWebview, { getWebContentsId: () => 202 }); + + firstWebview.dispatchEvent(new Event("did-attach")); + secondWebview.dispatchEvent(new Event("did-attach")); + + expect(firstWebview.getAttribute("partition")).toBe("persist:paseo-browser"); + expect(secondWebview.getAttribute("partition")).toBe("persist:paseo-browser"); + expect(attachedBrowsers).toEqual([ + { browserId: "browser-first", workspaceId: "workspace-a", webContentsId: 101 }, + { browserId: "browser-second", workspaceId: "workspace-b", webContentsId: 202 }, + ]); + }); + it("normalizes an existing resident host back to permanent parking", () => { const staleHost = document.createElement("div"); staleHost.id = RESIDENT_HOST_ID; @@ -96,8 +156,9 @@ describe("resident browser webviews", () => { staleHost.style.display = "none"; document.body.appendChild(staleHost); - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-stale-host", + workspaceId: "workspace-stale-host", url: "https://example.com", }); @@ -116,8 +177,9 @@ describe("resident browser webviews", () => { staleHost.style.display = "none"; const staleWebview = document.createElement("webview"); - prepareBrowserWebview(staleWebview, { + prepareTestBrowser(staleWebview, { browserId: "browser-stale-child", + workspaceId: "workspace-stale-child", initialUrl: "https://example.com", }); staleWebview.style.display = "none"; @@ -128,8 +190,9 @@ describe("resident browser webviews", () => { staleHost.appendChild(staleWebview); document.body.appendChild(staleHost); - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-stale-child", + workspaceId: "workspace-stale-child", url: "https://example.com/agent", }); @@ -140,12 +203,14 @@ describe("resident browser webviews", () => { }); it("parks resident webviews as an overlapping stack", () => { - const firstWebview = ensureResidentBrowserWebview({ + const firstWebview = ensureTestBrowser({ browserId: "browser-first", + workspaceId: "workspace-stack", url: "https://example.com/first", }); - const secondWebview = ensureResidentBrowserWebview({ + const secondWebview = ensureTestBrowser({ browserId: "browser-second", + workspaceId: "workspace-stack", url: "https://example.com/second", }); @@ -157,8 +222,9 @@ describe("resident browser webviews", () => { }); it("moves a resident webview into a visible pane without recreating the node", () => { - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-visible", + workspaceId: "workspace-visible", url: "https://example.com", }); @@ -175,15 +241,17 @@ describe("resident browser webviews", () => { it("returns an existing visible pane webview instead of creating a resident duplicate", () => { const visibleHost = document.createElement("div"); const visibleWebview = document.createElement("webview"); - prepareBrowserWebview(visibleWebview, { + prepareTestBrowser(visibleWebview, { browserId: "browser-visible-pane", + workspaceId: "workspace-visible-pane", initialUrl: "https://example.com", }); visibleHost.appendChild(visibleWebview); document.body.appendChild(visibleHost); - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-visible-pane", + workspaceId: "workspace-visible-pane", url: "https://example.com/agent", }); @@ -192,8 +260,9 @@ describe("resident browser webviews", () => { }); it("finds the originating browser webview for focus restoration", () => { - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-focus", + workspaceId: "workspace-focus", url: "https://example.com", }); @@ -205,8 +274,9 @@ describe("resident browser webviews", () => { }); it("removes a resident webview when its browser tab closes", () => { - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-closed", + workspaceId: "workspace-closed", url: "https://example.com", }); diff --git a/packages/app/src/components/browser-webview-resident.ts b/packages/app/src/components/browser-webview-resident.ts index ed819ee6e..a97190018 100644 --- a/packages/app/src/components/browser-webview-resident.ts +++ b/packages/app/src/components/browser-webview-resident.ts @@ -1,3 +1,9 @@ +import { + getDesktopHost, + type DesktopAttachedBrowserRegistration, + type DesktopBrowserBridge, +} from "@/desktop/host"; + const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews"; const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id"; const RESIDENT_VIEWPORT_WIDTH = 1280; @@ -8,6 +14,62 @@ const residentWebviewSizesByBrowserId = new Map; +} + +function isAttachedBrowserBridge( + browser: DesktopBrowserBridge | undefined, +): browser is BrowserWebviewProfileHost { + return ( + browser !== undefined && + typeof browser.profilePartition === "string" && + browser.profilePartition.startsWith("persist:") && + typeof browser.registerAttachedBrowser === "function" + ); +} + +function getBrowserBridge(override?: BrowserWebviewProfileHost): BrowserWebviewProfileHost { + if (override) { + return override; + } + const browser = getDesktopHost()?.browser; + if (!isAttachedBrowserBridge(browser)) { + throw new Error("Electron browser profile bridge is unavailable"); + } + return browser; +} + +function registerBrowserWhenAttached( + webview: BrowserWebviewElement, + identity: BrowserWebviewIdentity, + browser: BrowserWebviewProfileHost, +): void { + webview.addEventListener( + "did-attach", + () => { + const webContentsId = webview.getWebContentsId(); + void browser + .registerAttachedBrowser({ + browserId: identity.browserId, + workspaceId: identity.workspaceId, + webContentsId, + }) + .catch((error) => { + console.error("[browser-webview] attached registration failed", error); + }); + }, + { once: true }, + ); } function trimNonEmpty(value: string | null | undefined): string | null { @@ -104,21 +166,30 @@ function clearResidentWebviewParkingStyle(webview: HTMLElement): void { export function prepareBrowserWebview( webview: HTMLElement, - input: { browserId: string; initialUrl?: string | null }, + input: { + browserId: string; + workspaceId: string; + initialUrl?: string | null; + profileHost?: BrowserWebviewProfileHost; + }, ): void { + const browser = getBrowserBridge(input.profileHost); webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId); - webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`); + webview.setAttribute("partition", browser.profilePartition); webview.setAttribute("allowpopups", "true"); webview.setAttribute("spellcheck", "false"); webview.setAttribute("autosize", "on"); if (input.initialUrl) { (webview as BrowserWebviewElement).src = input.initialUrl; } + registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser); } export function ensureResidentBrowserWebview(input: { browserId: string; + workspaceId: string; url: string; + profileHost?: BrowserWebviewProfileHost; }): HTMLElement | null { const browserId = trimNonEmpty(input.browserId); if (!browserId) { @@ -144,7 +215,12 @@ export function ensureResidentBrowserWebview(input: { } const webview = ownerDocument.createElement("webview") as BrowserWebviewElement; - prepareBrowserWebview(webview, { browserId, initialUrl: input.url }); + prepareBrowserWebview(webview, { + browserId, + workspaceId: input.workspaceId, + initialUrl: input.url, + profileHost: input.profileHost, + }); releaseResidentBrowserWebview(browserId, webview); return webview; } diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts new file mode 100644 index 000000000..98f226005 --- /dev/null +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + canDesktopAppSidebarShare, + resolveDesktopAppChromeLayout, + resolveDesktopAppContentMinimum, + resolveDesktopExplorerWidth, + resolveDesktopSidebarWidth, +} from "@/components/desktop-sidebar-layout"; + +describe("desktop sidebar layout", () => { + it("keeps the sidebar toggle window-owned beside left window controls", () => { + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: true, + hasTopLeftWindowControls: true, + sidebarControlsEnabled: true, + }), + ).toEqual({ + sidebarCorners: "top-left", + contentCorners: "top-right", + sidebarToggleOwner: "window", + }); + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: true, + hasTopLeftWindowControls: false, + sidebarControlsEnabled: true, + }), + ).toEqual({ + sidebarCorners: "none", + contentCorners: "both", + sidebarToggleOwner: "content", + }); + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: false, + hasTopLeftWindowControls: true, + sidebarControlsEnabled: true, + }), + ).toEqual({ + sidebarCorners: "none", + contentCorners: "both", + sidebarToggleOwner: "window", + }); + }); + + it("hides the window-owned sidebar toggle when app chrome is suppressed", () => { + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: false, + hasTopLeftWindowControls: true, + sidebarControlsEnabled: false, + }).sidebarToggleOwner, + ).toBe("none"); + }); + + it("clamps a persisted wide sidebar to preserve the center pane", () => { + const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 }); + expect(atHalfScreen).toBe(351); + expect(751 - atHalfScreen).toBe(400); + + const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 }); + expect(atBreakpoint).toBe(320); + expect(720 - atBreakpoint).toBe(400); + + expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600); + }); + + it("keeps a temporarily narrow explorer render-only", () => { + expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351); + expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400); + }); + + it("yields app navigation when settings or Explorer need the shell width", () => { + const settingsMinimum = resolveDesktopAppContentMinimum({ + isSettingsRoute: true, + isWorkspaceExplorerOpen: false, + requestedExplorerWidth: 400, + viewportWidth: 751, + }); + expect(settingsMinimum).toBe(720); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: settingsMinimum, + requestedSidebarWidth: 320, + viewportWidth: 751, + }), + ).toBe(false); + + const explorerMinimum = resolveDesktopAppContentMinimum({ + isSettingsRoute: false, + isWorkspaceExplorerOpen: true, + requestedExplorerWidth: 400, + viewportWidth: 751, + }); + expect(explorerMinimum).toBe(751); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: explorerMinimum, + requestedSidebarWidth: 320, + viewportWidth: 751, + }), + ).toBe(false); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: resolveDesktopAppContentMinimum({ + isSettingsRoute: false, + isWorkspaceExplorerOpen: true, + requestedExplorerWidth: 400, + viewportWidth: 1120, + }), + requestedSidebarWidth: 320, + viewportWidth: 1120, + }), + ).toBe(true); + }); +}); diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts new file mode 100644 index 000000000..fc0a07d82 --- /dev/null +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -0,0 +1,95 @@ +import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout"; +import { + MAX_EXPLORER_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_EXPLORER_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, +} from "@/stores/panel-store"; + +export const MIN_DESKTOP_CENTER_WIDTH = 400; + +export function resolveDesktopAppChromeLayout(input: { + desktopSidebarRendered: boolean; + hasTopLeftWindowControls: boolean; + sidebarControlsEnabled: boolean; +}) { + const sidebarOwnsTopLeft = input.desktopSidebarRendered && input.hasTopLeftWindowControls; + let sidebarToggleOwner: "none" | "window" | "content" = "none"; + if (input.sidebarControlsEnabled) { + sidebarToggleOwner = input.hasTopLeftWindowControls ? "window" : "content"; + } + return { + sidebarCorners: sidebarOwnsTopLeft ? ("top-left" as const) : ("none" as const), + contentCorners: sidebarOwnsTopLeft ? ("top-right" as const) : ("both" as const), + sidebarToggleOwner, + }; +} + +function resolveDesktopPanelWidth(input: { + requestedWidth: number; + viewportWidth: number; + minimumWidth: number; + maximumWidth: number; +}): number { + "worklet"; + const maximumVisibleWidth = Math.max( + input.minimumWidth, + Math.min(input.maximumWidth, input.viewportWidth - MIN_DESKTOP_CENTER_WIDTH), + ); + return Math.max(input.minimumWidth, Math.min(maximumVisibleWidth, input.requestedWidth)); +} + +export function resolveDesktopSidebarWidth(input: { + requestedWidth: number; + viewportWidth: number; +}): number { + "worklet"; + return resolveDesktopPanelWidth({ + ...input, + minimumWidth: MIN_SIDEBAR_WIDTH, + maximumWidth: MAX_SIDEBAR_WIDTH, + }); +} + +export function resolveDesktopExplorerWidth(input: { + requestedWidth: number; + viewportWidth: number; +}): number { + "worklet"; + return resolveDesktopPanelWidth({ + ...input, + minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH, + maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH, + }); +} + +export function resolveDesktopAppContentMinimum(input: { + isSettingsRoute: boolean; + isWorkspaceExplorerOpen: boolean; + requestedExplorerWidth: number; + viewportWidth: number; +}): number { + const workspaceMinimum = input.isWorkspaceExplorerOpen + ? MIN_DESKTOP_CENTER_WIDTH + + resolveDesktopExplorerWidth({ + requestedWidth: input.requestedExplorerWidth, + viewportWidth: input.viewportWidth, + }) + : 0; + return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum); +} + +export function canDesktopAppSidebarShare(input: { + contentMinimumWidth: number; + requestedSidebarWidth: number; + viewportWidth: number; +}): boolean { + return ( + input.viewportWidth - + resolveDesktopSidebarWidth({ + requestedWidth: input.requestedSidebarWidth, + viewportWidth: input.viewportWidth, + }) >= + input.contentMinimumWidth + ); +} diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 8af11ee0e..4caa3ac40 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,6 +1,5 @@ -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; interface DiffScrollProps { children: React.ReactNode; @@ -16,8 +15,6 @@ export function DiffScroll({ style, contentContainerStyle, }: DiffScrollProps) { - const webScrollbarStyle = useWebScrollbarStyle(); - const combinedStyle = useMemo(() => [style, webScrollbarStyle], [style, webScrollbarStyle]); const handleLayout = useCallback( (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), [onScrollViewWidthChange], @@ -28,7 +25,7 @@ export function DiffScroll({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={combinedStyle} + style={style} contentContainerStyle={contentContainerStyle} onLayout={handleLayout} > diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 26f22c6b8..2362363b9 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -6,7 +6,6 @@ import { StyleSheet } from "react-native-unistyles"; import type { DiffLine } from "@/utils/tool-call-parsers"; import { diffLinePrefix } from "@/utils/diff-highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { getCodeInsets } from "./code-insets"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; @@ -127,7 +126,6 @@ export function DiffViewer({ const { t } = useTranslation(); const [scrollViewWidth, setScrollViewWidth] = React.useState(0); const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty"); - const webScrollbarStyle = useWebScrollbarStyle(); const handleInnerLayout = React.useCallback( (e: { nativeEvent: { layout: { width: number } } }) => setScrollViewWidth(e.nativeEvent.layout.width), @@ -139,9 +137,8 @@ export function DiffViewer({ styles.verticalScroll, maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }), fillAvailableHeight && styles.fillHeight, - webScrollbarStyle, ], - [maxHeight, fillAvailableHeight, webScrollbarStyle], + [maxHeight, fillAvailableHeight], ); const linesContainerStyle = React.useMemo( () => [ @@ -180,7 +177,6 @@ export function DiffViewer({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={webScrollbarStyle} contentContainerStyle={styles.horizontalContent} onLayout={handleInnerLayout} > diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 2de511ca8..554dcafa3 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -24,7 +24,6 @@ export function DraggableList({ ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, - enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false, scrollEnabled = true, useDragHandle: _useDragHandle = false, refreshing, diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index 01b15c640..74b365ac6 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -34,7 +34,6 @@ export interface DraggableListProps { ListHeaderComponent?: ReactElement | null; ListEmptyComponent?: ReactElement | null; showsVerticalScrollIndicator?: boolean; - enableDesktopWebScrollbar?: boolean; /** When false, disables internal scrolling (use outer list to scroll). */ scrollEnabled?: boolean; /** diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 971575255..3f16fc072 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -17,7 +17,6 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; -import { useWebScrollViewScrollbar } from "./use-web-scrollbar"; import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -133,7 +132,6 @@ export function DraggableList({ ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, - enableDesktopWebScrollbar = false, scrollEnabled = true, extraData: _extraData, useDragHandle = false, @@ -147,11 +145,6 @@ export function DraggableList({ onDragEnd, onDragBegin, }); - const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled; - const scrollViewRef = useRef(null); - const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { - enabled: showCustomScrollbar, - }); const pointerActivationConstraint = getPointerActivationConstraint( useDragHandle, POINTER_ACTIVATION_CONFIG, @@ -183,15 +176,10 @@ export function DraggableList({ {scrollEnabled ? ( {ListHeaderComponent} {items.length === 0 && ListEmptyComponent} @@ -254,7 +242,6 @@ export function DraggableList({ {ListFooterComponent} )} - {scrollbar.overlay} ); } diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 1d964dc7a..942818b97 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -22,13 +22,7 @@ import { } from "@/git/pull-request-panel"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; -import { - usePanelStore, - selectIsFileExplorerOpen, - MIN_EXPLORER_SIDEBAR_WIDTH, - MAX_EXPLORER_SIDEBAR_WIDTH, - type ExplorerTab, -} from "@/stores/panel-store"; +import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; import { useToast } from "@/contexts/toast-context"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; @@ -36,13 +30,13 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; -const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} interface ExplorerSidebarProps { @@ -136,7 +130,6 @@ export function CompactExplorerSidebar({ workspaceId={workspaceId} workspaceRoot={workspaceRoot} isGit={isGit} - isMobile isOpen={isOpen} onOpenFile={onOpenFile} /> @@ -163,18 +156,16 @@ export function ExplorerSidebar({ isGit, }); const { width: viewportWidth } = useWindowDimensions(); - const startWidthRef = useRef(explorerWidth); - const resizeWidth = useSharedValue(explorerWidth); + const visibleExplorerWidth = resolveDesktopExplorerWidth({ + requestedWidth: explorerWidth, + viewportWidth, + }); + const startWidthRef = useRef(visibleExplorerWidth); + const resizeWidth = useSharedValue(visibleExplorerWidth); useEffect(() => { - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - if (explorerWidth > maxWidth) { - setExplorerWidth(maxWidth); - } - }, [explorerWidth, setExplorerWidth, viewportWidth]); + resizeWidth.value = visibleExplorerWidth; + }, [resizeWidth, visibleExplorerWidth]); const handleDesktopClose = useCallback(() => { logExplorerSidebar("handleClose", { @@ -190,22 +181,20 @@ export function ExplorerSidebar({ .enabled(true) .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { - startWidthRef.current = explorerWidth; - resizeWidth.value = explorerWidth; + startWidthRef.current = visibleExplorerWidth; + resizeWidth.value = visibleExplorerWidth; }) .onUpdate((event) => { const newWidth = startWidthRef.current - event.translationX; - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); - resizeWidth.value = clampedWidth; + resizeWidth.value = resolveDesktopExplorerWidth({ + requestedWidth: newWidth, + viewportWidth, + }); }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); }), - [explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], + [resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ @@ -235,7 +224,6 @@ export function ExplorerSidebar({ workspaceId={workspaceId} workspaceRoot={workspaceRoot} isGit={isGit} - isMobile={false} isOpen={isOpen} onOpenFile={onOpenFile} /> @@ -280,7 +268,6 @@ interface SidebarContentProps { workspaceId?: string | null; workspaceRoot: string; isGit: boolean; - isMobile: boolean; isOpen: boolean; onOpenFile?: (filePath: string) => void; } @@ -293,14 +280,13 @@ function ExplorerSidebarContent({ workspaceId, workspaceRoot, isGit, - isMobile, isOpen, onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const toast = useToast(); - const padding = useWindowControlsPadding("explorerSidebar"); + const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ serverId, @@ -325,15 +311,15 @@ function ExplorerSidebarContent({ [serverId, workspaceId, workspaceRoot], ); - const headerStyle = useMemo( - () => [styles.header, { paddingRight: padding.right }], - [padding.right], - ); - return ( {/* Header with tabs and close button */} - + {isGit && ( @@ -370,13 +356,29 @@ function ExplorerSidebarContent({ )} - {isMobile && ( - - + {!hasRightWindowControls && ( + + {({ hovered, pressed }) => ( + + )} )} - + {/* Content based on active tab */} @@ -476,7 +478,6 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index aee2cbf07..6707b417c 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -13,7 +13,6 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import * as Clipboard from "expo-clipboard"; import { SvgXml } from "react-native-svg"; @@ -46,8 +45,6 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility"; -import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; -import { isWeb } from "@/constants/platform"; const SORT_OPTIONS: { value: SortOption }[] = [ { value: "name" }, @@ -224,8 +221,6 @@ export function FileExplorerPane({ onOpenFile, }: FileExplorerPaneProps) { const { t } = useTranslation(); - const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = isWeb && !isMobile; const daemons = useHosts(); const daemonProfile = useMemo( @@ -283,9 +278,6 @@ export function FileExplorerPane({ ); const treeListRef = useRef>(null); - const scrollbar = useWebScrollViewScrollbar(treeListRef, { - enabled: showDesktopWebScrollbar, - }); const hasInitializedRef = useRef(false); @@ -482,9 +474,7 @@ export function FileExplorerPane({ treeRows={treeRows} currentSortLabel={currentSortLabel} isRefreshFetching={isRefreshFetching} - showDesktopWebScrollbar={showDesktopWebScrollbar} treeListRef={treeListRef} - scrollbar={scrollbar} renderTreeRow={renderTreeRow} handleSortCycle={handleSortCycle} handleToggleHiddenFiles={handleToggleHiddenFiles} @@ -505,9 +495,7 @@ interface FileExplorerPaneContentProps { treeRows: TreeRow[]; currentSortLabel: string; isRefreshFetching: boolean; - showDesktopWebScrollbar: boolean; treeListRef: RefObject | null>; - scrollbar: ReturnType; renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; handleToggleHiddenFiles: () => void; @@ -528,9 +516,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { treeRows, currentSortLabel, isRefreshFetching, - showDesktopWebScrollbar, treeListRef, - scrollbar, renderTreeRow, handleSortCycle, handleToggleHiddenFiles, @@ -645,17 +631,12 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { keyExtractor={treeRowKeyExtractor} testID="file-explorer-tree-scroll" contentContainerStyle={styles.entriesContent} - onLayout={scrollbar.onLayout} - onScroll={scrollbar.onScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> )} - {treeRows.length > 0 ? scrollbar.overlay : null} ); } diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 8163b8c03..e8cde4141 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -13,15 +13,12 @@ import { useTranslation } from "react-i18next"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; -import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; -import { isWeb } from "@/constants/platform"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes } from "@/attachments/service"; @@ -43,7 +40,6 @@ interface CodeLineProps { interface FilePreviewBodyProps { preview: ExplorerFile | null; isLoading: boolean; - showDesktopWebScrollbar: boolean; isMobile: boolean; location: WorkspaceFileLocation; imagePreviewUri: string | null; @@ -192,7 +188,6 @@ const codeLineStyles = StyleSheet.create((theme) => ({ function FilePreviewBody({ preview, isLoading, - showDesktopWebScrollbar, isMobile, location, imagePreviewUri, @@ -204,10 +199,6 @@ function FilePreviewBody({ preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; const previewScrollRef = useRef(null); - const webScrollbarStyle = useWebScrollbarStyle(); - const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { - enabled: showDesktopWebScrollbar, - }); const highlightedLines = useMemo(() => { if (!preview || preview.kind !== "text" || isMarkdownFile) { @@ -276,15 +267,10 @@ function FilePreviewBody({ ref={previewScrollRef} style={styles.previewContent} contentContainerStyle={styles.previewMarkdownScrollContent} - onLayout={scrollbar.onLayout} - onScroll={scrollbar.onScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator > - {scrollbar.overlay} ); } @@ -318,11 +304,7 @@ function FilePreviewBody({ {isMobile ? ( {codeLines} @@ -331,14 +313,12 @@ function FilePreviewBody({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={webScrollbarStyle} contentContainerStyle={styles.previewCodeScrollContent} > {codeLines} )} - {scrollbar.overlay} ); } @@ -359,11 +339,7 @@ function FilePreviewBody({ ref={previewScrollRef} style={styles.previewContent} contentContainerStyle={styles.previewImageScrollContent} - onLayout={scrollbar.onLayout} - onScroll={scrollbar.onScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator > - {scrollbar.overlay} ); } @@ -395,7 +370,6 @@ export function FilePane({ }) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = isWeb && !isMobile; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); @@ -463,7 +437,6 @@ export function FilePane({ & { + isMobile: boolean; + resolvedStyle: StyleProp; +}) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const isMobile = useIsCompactFormFactor(); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); const toggleShortcutKeys = useMemo( @@ -58,9 +62,6 @@ export function SidebarMenuToggle({ [], ); - const menuIconColor = - !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; - const handlePress = useCallback(() => { toggleAgentListForLayout({ isCompact: isMobile }); }, [toggleAgentListForLayout, isMobile]); @@ -75,21 +76,54 @@ export function SidebarMenuToggle({ tooltipSide={tooltipSide} testID={testID} nativeID={nativeID} - style={style} + style={resolvedStyle} accessible accessibilityRole="button" accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")} accessibilityState={accessibilityState} > - {isMobile ? ( - - ) : ( - - )} + {({ hovered, pressed }) => { + const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted; + return isMobile ? ( + + ) : ( + + ); + }} ); } +export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const isMobile = useIsCompactFormFactor(); + const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); + const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + const placeholderStyle = useMemo( + () => [headerIconSlotStyle.slot, resolvedStyle], + [resolvedStyle], + ); + + if (!isMobile && !ownsTopLeft) { + return null; + } + + if (!isMobile && hasTopLeftWindowControls) { + return ( + + + + ); + } + + return ; +} + +export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + return ; +} + export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) { return ( ({ + leadingToggle: { + marginLeft: { + xs: 0, + md: -theme.spacing[2], + }, + }, left: { gap: theme.spacing[2], }, @@ -116,6 +156,10 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "space-between", alignItems: "flex-start", }, + desktopMenuIconSpace: { + width: theme.iconSize.md, + height: theme.iconSize.md, + }, mobileMenuLine: { width: MOBILE_MENU_LINE_WIDTH, height: MOBILE_MENU_LINE_HEIGHT, diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index ce2ca302f..c2bd2f5d3 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -9,7 +9,7 @@ import { HEADER_TOP_PADDING_MOBILE, useIsCompactFormFactor, } from "@/constants/layout"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; interface ScreenHeaderProps { @@ -18,7 +18,6 @@ interface ScreenHeaderProps { leftStyle?: StyleProp; rightStyle?: StyleProp; borderless?: boolean; - windowControlsPaddingRole?: "header" | "detailHeader"; onRowLayout?: (event: LayoutChangeEvent) => void; } @@ -32,43 +31,36 @@ export function ScreenHeader({ leftStyle, rightStyle, borderless, - windowControlsPaddingRole = "header", onRowLayout, }: ScreenHeaderProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = useIsCompactFormFactor(); - const padding = useWindowControlsPadding(windowControlsPaddingRole); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; - const baseHorizontalPadding = theme.spacing[2]; + const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3]; const innerStyle = useMemo( () => [styles.inner, { paddingTop: insets.top + topPadding }], [insets.top, topPadding], ); - const rowStyle = useMemo( - () => [ - styles.row, - { - paddingLeft: baseHorizontalPadding + padding.left, - paddingRight: baseHorizontalPadding + padding.right, - }, - borderless && styles.borderless, - ], - [baseHorizontalPadding, padding.left, padding.right, borderless], - ); + const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]); const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]); const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]); return ( - + {left} {right} - + ); @@ -88,7 +80,6 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[2], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, userSelect: "none", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index db0145b47..cd5be3247 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -25,13 +25,14 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-nativ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; @@ -50,13 +51,8 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspace } from "@/stores/session-store-hooks"; -import { - MAX_SIDEBAR_WIDTH, - MIN_SIDEBAR_WIDTH, - selectIsAgentListOpen, - usePanelStore, -} from "@/stores/panel-store"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { usePanelStore } from "@/stores/panel-store"; +import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { @@ -73,8 +69,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; -const MIN_CHAT_WIDTH = 400; - type SidebarTheme = ReturnType["theme"]; interface SidebarSharedProps { @@ -123,19 +117,16 @@ interface MobileSidebarProps extends SidebarSharedProps { interface DesktopSidebarProps extends SidebarSharedProps { insetsTop: number; - isOpen: boolean; + active: boolean; handleViewMore: () => void; handleViewSchedules: () => void; } -export const LeftSidebar = memo(function LeftSidebar() { +export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) { const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const isCompactLayout = useIsCompactFormFactor(); - const isOpen = usePanelStore((state) => - selectIsAgentListOpen(state, { isCompact: isCompactLayout }), - ); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const { @@ -262,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar() { if (isCompactLayout) { return ( - + + + - ); @@ -603,6 +594,7 @@ function MobileSidebar({ panelStyle={mobileSidebarInsetStyle} > + - - {({ hovered, pressed }) => ( - - )} - + + + {({ hovered, pressed }) => ( + + )} + + {isInitialLoad && !hasActiveHostFilter ? ( @@ -705,79 +699,94 @@ function DesktopSidebar({ handleAddHost, handleOpenHostSettings, insetsTop, - isOpen, + active, handleViewMore, handleViewSchedules, }: DesktopSidebarProps) { + const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); const pathname = usePathname(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); - const padding = useWindowControlsPadding("sidebar"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); + const visibleSidebarWidth = resolveDesktopSidebarWidth({ + requestedWidth: sidebarWidth, + viewportWidth, + }); - const startWidthRef = useRef(sidebarWidth); - const resizeWidth = useSharedValue(sidebarWidth); + const startWidthRef = useRef(visibleSidebarWidth); + const resizeWidth = useSharedValue(visibleSidebarWidth); useEffect(() => { - resizeWidth.value = sidebarWidth; - }, [sidebarWidth, resizeWidth]); + resizeWidth.value = visibleSidebarWidth; + }, [resizeWidth, visibleSidebarWidth]); const resizeGesture = useMemo( () => Gesture.Pan() .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { - startWidthRef.current = sidebarWidth; - resizeWidth.value = sidebarWidth; + startWidthRef.current = visibleSidebarWidth; + resizeWidth.value = visibleSidebarWidth; }) .onUpdate((event) => { // Dragging right (positive translationX) increases width const newWidth = startWidthRef.current + event.translationX; - const maxWidth = Math.max( - MIN_SIDEBAR_WIDTH, - Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); - resizeWidth.value = clampedWidth; + resizeWidth.value = resolveDesktopSidebarWidth({ + requestedWidth: newWidth, + viewportWidth, + }); }) .onEnd(() => { runOnJS(setSidebarWidth)(resizeWidth.value); }), - [sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth], + [resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); - const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const desktopSidebarStyle = useMemo( - () => [staticStyles.desktopSidebar, resizeAnimatedStyle], - [resizeAnimatedStyle], + () => [ + staticStyles.desktopSidebar, + !active && staticStyles.desktopSidebarHidden, + resizeAnimatedStyle, + ], + [active, resizeAnimatedStyle], ); const desktopSidebarBorderStyle = useMemo( () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], [insetsTop], ); + const sidebarHeaderGroupStyle = useMemo( + () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], + [ownsTopLeft], + ); const resizeHandleStyle = useMemo( () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], [], ); - if (!isOpen) { - return null; - } - return ( - + - - {padding.top > 0 ? : null} - + {ownsTopLeft ? ( + + + + ) : ( + + )} + ({ @@ -923,6 +935,9 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, + sidebarHeaderGroupBelowChrome: { + paddingTop: 0, + }, workspacesSectionHeader: { flexDirection: "row", alignItems: "center", @@ -960,11 +975,17 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - mobileCloseButton: { + mobileCloseButtonRow: { position: "absolute", top: theme.spacing[3], - right: theme.spacing[4], + left: 0, + right: 0, zIndex: 2, + alignItems: "flex-end", + pointerEvents: "box-none", + }, + mobileCloseButton: { + marginRight: theme.spacing[4], width: 32, height: 32, alignItems: "center", @@ -988,6 +1009,14 @@ const styles = StyleSheet.create((theme) => ({ sidebarDragArea: { position: "relative", }, + desktopChromeRow: { + position: "relative", + height: HEADER_INNER_HEIGHT, + flexDirection: "row", + alignItems: "center", + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: "transparent", + }, sidebarFooter: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index af51a82ca..08792bfd5 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -60,6 +60,7 @@ import Animated, { } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer"; import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; @@ -115,6 +116,7 @@ import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu"; import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation"; import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; +import { useRetainedPanelActive } from "@/components/retained-panel"; export type { InlinePathTarget } from "@/assistant-file-links"; export type { AssistantForkTarget }; @@ -562,11 +564,7 @@ interface AssistantTurnFooterProps { getContent: () => string; completedAt?: Date; durationMs?: number; - forkBoundaryMessageId?: string; - onFork?: (input: { - target: AssistantForkTarget; - boundaryMessageId?: string; - }) => Promise | void; + onFork?: (target: AssistantForkTarget) => Promise | void; } const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ @@ -611,7 +609,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, - forkBoundaryMessageId, onFork, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); @@ -654,11 +651,11 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ }, [canSwap]); const handleFork = useCallback( (target: AssistantForkTarget) => { - return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId }); + return onFork?.(target); }, - [forkBoundaryMessageId, onFork], + [onFork], ); - const canFork = Boolean(onFork && forkBoundaryMessageId); + const canFork = Boolean(onFork); return ( @@ -1273,7 +1270,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ }, chevron: { flexShrink: 0, - transform: [{ scale: 1.3 }], }, openFileButton: { marginLeft: theme.spacing[1], @@ -1285,9 +1281,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ width: 14, height: 14, }, - chevronExpanded: { - transform: [{ scale: 1.3 }, { rotate: "90deg" }], - }, detailWrapper: { borderBottomLeftRadius: theme.borderRadius.lg, borderBottomRightRadius: theme.borderRadius.lg, @@ -1302,11 +1295,16 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ ...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}), }, pressableExpanded: { - borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, + }, + pressableExpandedAttached: { + borderColor: theme.colors.border, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + detailWrapperBorderless: { + borderWidth: 0, + }, shimmerOverlay: { position: "absolute", top: 0, @@ -1358,9 +1356,14 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer( durationSeconds, gradientId, }: NativeExpandableBadgeShimmerProps) { + const isPanelActive = useRetainedPanelActive(); const shimmerTranslateX = useSharedValue(0); useEffect(() => { + if (!isPanelActive) { + cancelAnimation(shimmerTranslateX); + return; + } const startPosition = -peakWidth; const endPosition = rowWidth + peakWidth; shimmerTranslateX.value = startPosition; @@ -1375,7 +1378,7 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer( return () => { cancelAnimation(shimmerTranslateX); }; - }, [durationSeconds, peakWidth, rowWidth, shimmerTranslateX]); + }, [durationSeconds, isPanelActive, peakWidth, rowWidth, shimmerTranslateX]); const nativeShimmerPeakStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslateX.value }], @@ -2366,6 +2369,7 @@ interface ExpandableBadgeProps { isError?: boolean; isLastInSequence?: boolean; disableOuterSpacing?: boolean; + borderlessWhenExpanded?: boolean; testID?: string; } @@ -2604,7 +2608,9 @@ function renderExpandableBadgeIconSlot({ }): ReactNode { if (showChevron) { return ( - + + + ); } return iconNode; @@ -2695,7 +2701,7 @@ function buildShimmerTextStyle(input: { offsetX: number; }): object | null { if (!input.isWebShimmer) return null; - return { + return inlineUnistylesStyle({ opacity: 1, color: "transparent", backgroundImage: SHIMMER_GRADIENT, @@ -2707,10 +2713,10 @@ function buildShimmerTextStyle(input: { animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`, "--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`, "--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`, - }; + }); } -const ExpandableBadge = memo(function ExpandableBadge({ +export const ExpandableBadge = memo(function ExpandableBadge({ label, style, secondaryLabel, @@ -2724,6 +2730,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ isError = false, isLastInSequence = false, disableOuterSpacing, + borderlessWhenExpanded = false, testID, }: ExpandableBadgeProps) { const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); @@ -2894,8 +2901,17 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.pressable, isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null, isExpanded && expandableBadgeStylesheet.pressableExpanded, + isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached, ], - [isExpanded, isInteractive, isPressed], + [borderlessWhenExpanded, isExpanded, isInteractive, isPressed], + ); + + const detailWrapperStyle = useMemo( + () => [ + expandableBadgeStylesheet.detailWrapper, + borderlessWhenExpanded && expandableBadgeStylesheet.detailWrapperBorderless, + ], + [borderlessWhenExpanded], ); const accessibilityState = useMemo( @@ -2944,8 +2960,10 @@ const ExpandableBadge = memo(function ExpandableBadge({ const chevronStyle = useMemo( () => [ expandableBadgeStylesheet.chevron, - isExpanded && expandableBadgeStylesheet.chevronExpanded, LUCIDE_CHEVRON_NUDGE_LEFT, + inlineUnistylesStyle({ + transform: isExpanded ? [{ scale: 1.3 }, { rotate: "90deg" }] : [{ scale: 1.3 }], + }), ], [isExpanded], ); @@ -2953,7 +2971,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]); const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon }); const iconSlotNode = renderExpandableBadgeIconSlot({ - showChevron: isInteractive && isHovered, + showChevron: isInteractive && (isHovered || isExpanded), chevronStyle, iconNode, }); @@ -3012,7 +3030,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ {detailContent ? ( @@ -3033,6 +3051,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa if (previous.isError !== next.isError) return false; if (previous.isLastInSequence !== next.isLastInSequence) return false; if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false; + if (previous.borderlessWhenExpanded !== next.borderlessWhenExpanded) return false; if (previous.testID !== next.testID) return false; if (previous.onToggle !== next.onToggle) return false; if (previous.onOpenFile !== next.onOpenFile) return false; @@ -3057,6 +3076,7 @@ interface ToolCallProps { onOpenFilePath?: (filePath: string) => void; defaultExpanded?: boolean; forceInline?: boolean; + maxDetailHeight?: number; } export const ToolCall = memo(function ToolCall({ @@ -3075,6 +3095,7 @@ export const ToolCall = memo(function ToolCall({ onOpenFilePath, defaultExpanded, forceInline = false, + maxDetailHeight = 400, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false); @@ -3175,11 +3196,17 @@ export const ToolCall = memo(function ToolCall({ ); - }, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]); + }, [ + shouldRenderInline, + effectiveDetail, + presentation.errorText, + presentation.isLoadingDetails, + maxDetailHeight, + ]); if (presentation.isPlan && effectiveDetail?.type === "plan") { return ( @@ -3224,5 +3251,6 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.onOpenFilePath !== next.onOpenFilePath) return false; if (previous.defaultExpanded !== next.defaultExpanded) return false; if (previous.forceInline !== next.forceInline) return false; + if (previous.maxDetailHeight !== next.maxDetailHeight) return false; return true; } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 1a7b1295d..721ceb225 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -432,7 +432,6 @@ function OpenScheduleFormSheet({ onClose={onClose} onDismiss={onDismiss} footer={footer} - webScrollbar testID="schedule-form-sheet" > ); +const changelogLeadingIcon = ( + +); + +function HostVersionHint({ host }: { host: HostProfile }) { + const { t } = useTranslation(); + const isConnected = useHostRuntimeIsConnected(host.serverId); + const daemonVersion = useSessionStore( + (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, + ); + const version = isConnected + ? formatVersionWithPrefix(daemonVersion) + : t("settings.about.offline"); + + return ( + + {host.label} {version} + + ); +} export function SidebarHelpMenu() { const { t } = useTranslation(); @@ -56,6 +84,7 @@ export function SidebarHelpMenu() { const [open, setOpen] = useState(false); const showKeyboardShortcuts = !isNative && !isCompactLayout; const version = formatVersionWithPrefix(resolveAppVersion()); + const hosts = useHosts(); const openKeyboardShortcuts = useCallback(() => { setShortcutsDialogOpen(true); @@ -69,6 +98,10 @@ export function SidebarHelpMenu() { void openExternalUrl(GITHUB_ISSUE_URL); }, []); + const openChangelog = useCallback(() => { + void openExternalUrl(CHANGELOG_URL); + }, []); + return ( @@ -94,30 +127,34 @@ export function SidebarHelpMenu() { - {t("sidebar.help.troubleshoot")} - - {t("sidebar.help.diagnostics")} - + {t("sidebar.help.sectionHelp")} {showKeyboardShortcuts ? ( {t("sidebar.help.shortcuts")} ) : null} + + {t("sidebar.help.whatsNew")} + + + {t("sidebar.help.diagnostics")} + {t("sidebar.help.reportIssue")} @@ -125,16 +162,20 @@ export function SidebarHelpMenu() { {t("sidebar.help.github")} - - {t("sidebar.help.version", { version })} - + + + {t("sidebar.help.version", { version })} + + {hosts.map((host) => ( + + ))} + ); @@ -153,4 +194,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, + versionList: { + gap: theme.spacing[1], + paddingVertical: theme.spacing[2], + }, + versionHint: { + paddingVertical: 0, + }, })); diff --git a/packages/app/src/components/split-container-focus.test.ts b/packages/app/src/components/split-container-focus.test.ts new file mode 100644 index 000000000..5e67d784a --- /dev/null +++ b/packages/app/src/components/split-container-focus.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { resolveSplitContainerRoot } from "@/components/split-container-focus"; +import type { SplitNode } from "@/stores/workspace-layout-store"; + +const pane = (id: string): SplitNode => ({ + kind: "pane", + pane: { id, tabIds: [], focusedTabId: null }, +}); +const root: SplitNode = { + kind: "group", + group: { + id: "root", + direction: "horizontal", + children: [pane("left"), pane("right")], + sizes: [0.5, 0.5], + }, +}; + +describe("split focus root", () => { + it("renders only the valid focused pane in focus mode", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: true }), + ).toEqual({ root: pane("right"), usesFallbackStrip: false }); + }); + + it("keeps the full tree and reserves the boundary strip when focus is missing", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "missing", focusModeEnabled: true }), + ).toEqual({ root, usesFallbackStrip: true }); + }); + + it("keeps normal splits unclaimed", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: false }), + ).toEqual({ root, usesFallbackStrip: false }); + }); +}); diff --git a/packages/app/src/components/split-container-focus.ts b/packages/app/src/components/split-container-focus.ts new file mode 100644 index 000000000..e16f4571d --- /dev/null +++ b/packages/app/src/components/split-container-focus.ts @@ -0,0 +1,21 @@ +import type { SplitNode, SplitPane } from "@/stores/workspace-layout-store"; + +export function resolveSplitContainerRoot(input: { + root: SplitNode; + focusedPaneId: string | null; + focusModeEnabled: boolean | undefined; +}): { root: SplitNode; usesFallbackStrip: boolean } { + if (!input.focusModeEnabled) return { root: input.root, usesFallbackStrip: false }; + const focusedPane = input.focusedPaneId ? findPane(input.root, input.focusedPaneId) : null; + if (!focusedPane) return { root: input.root, usesFallbackStrip: true }; + return { root: { kind: "pane", pane: focusedPane }, usesFallbackStrip: false }; +} + +function findPane(node: SplitNode, paneId: string): SplitPane | null { + if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null; + for (const child of node.group.children) { + const pane = findPane(child, paneId); + if (pane) return pane; + } + return null; +} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index bf90d1f98..c9e4294f9 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -32,8 +32,14 @@ import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { RetainedPanel } from "@/components/retained-panel"; +import { resolveSplitContainerRoot } from "@/components/split-container-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { + WindowChromeRegion, + WindowChromeSafeArea, + useWindowChromeCorners, + type WindowChromeCorners, +} from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { computeTabDropPreview, @@ -158,6 +164,7 @@ interface SplitNodeViewProps extends Omit { pane: SplitPane; uiTabs: WorkspaceTab[]; @@ -383,6 +391,8 @@ export function SplitContainer({ renderPaneEmptyState = () => null, focusModeEnabled, }: SplitContainerProps) { + const inheritedWindowChromeCorners = useWindowChromeCorners(); + const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; const [activeDragTabId, setActiveDragTabId] = useState(null); const [dropPreview, setDropPreview] = useState(null); const [tabDropPreview, setTabDropPreview] = useState(null); @@ -399,18 +409,16 @@ export function SplitContainer({ ); const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); - - const effectiveRoot = useMemo(() => { - if (!focusModeEnabled) { - return layout.root; - } - const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null; - if (!focusedPane) { - return layout.root; - } - return { kind: "pane" as const, pane: focusedPane }; - }, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]); - const renderRoot = useMemo(() => wrapRootPaneForStableMount(effectiveRoot), [effectiveRoot]); + const splitRoot = useMemo( + () => + resolveSplitContainerRoot({ + root: layout.root, + focusedPaneId: layout.focusedPaneId, + focusModeEnabled, + }), + [focusModeEnabled, layout.focusedPaneId, layout.root], + ); + const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); const handleDragStart = useCallback((event: DragStartEvent) => { const data = asWorkspaceTabDragData(event.active.data.current); @@ -565,6 +573,7 @@ export function SplitContainer({ onDragCancel={handleDragCancel} onDragEnd={handleDragEnd} > + {splitRoot.usesFallbackStrip && } {activeDragTabId ? ( @@ -744,6 +754,7 @@ function SplitNodeView({ showDropZones, dropPreview, tabDropPreview, + windowChromeCorners, }: SplitNodeViewProps) { const groupId = node.kind === "group" ? node.group.id : null; const groupDirection = node.kind === "group" ? node.group.direction : null; @@ -762,41 +773,43 @@ function SplitNodeView({ if (node.kind === "pane") { return ( - + + + ); } @@ -843,6 +856,7 @@ function SplitNodeView({ showDropZones={showDropZones} dropPreview={dropPreview} tabDropPreview={tabDropPreview} + windowChromeCorners={windowChromeCorners} /> {index < node.group.children.length - 1 ? ( @@ -898,7 +912,6 @@ function SplitPaneView({ const { theme: _theme } = useUnistyles(); const paneRef = useRef(null); const stableOnFocusPane = useStableEvent(onFocusPane); - const padding = useWindowControlsPadding("tabRow"); const paneState = useMemo( () => deriveWorkspacePaneState({ @@ -995,15 +1008,11 @@ function SplitPaneView({ () => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }), [onSplitPaneEmpty, paneId], ); - const paneTabsStyle = useMemo( - () => [styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }], - [padding.left, padding.right], - ); return ( - + - + {mountedPaneTabIds.length > 0 diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index e72ad13b4..a6054888d 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -10,7 +10,6 @@ import { type CSSProperties, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, type Ref, } from "react"; import type { DOMProps } from "expo/dom"; @@ -31,10 +30,6 @@ import type { import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import { openExternalUrl } from "../utils/open-external-url"; import { focusWithRetries } from "../utils/web-focus"; -import { - computeScrollOffsetFromDragDelta, - computeVerticalScrollbarGeometry, -} from "./web-desktop-scrollbar.math"; import { extractTerminalDropPaths, isTerminalDragLeaveOutside, @@ -51,20 +46,6 @@ export interface TerminalEmulatorHandle { blur: () => void; } -const SCROLLBAR_HANDLE_WIDTH_IDLE = 6; -const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9; -const SCROLLBAR_HANDLE_GRAB_WIDTH = 18; -const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8; -const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62; -const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78; -const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9; -const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220; -const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240; -const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90; -const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200; -const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110; -const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style"; - const HOST_DIV_STYLE: CSSProperties = { flex: 1, minHeight: 0, @@ -79,25 +60,6 @@ const HOST_DIV_STYLE: CSSProperties = { paddingRight: 0, }; -const SCROLLBAR_CONTAINER_STYLE: CSSProperties = { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - width: 12, - display: "flex", - alignItems: "center", - justifyContent: "flex-start", - zIndex: 10, - pointerEvents: "none", -}; - -interface ViewportMetrics { - offset: number; - viewportSize: number; - contentSize: number; -} - function buildXtermThemeKey(theme: ITheme): string { const values: Array = [ theme.background, @@ -169,10 +131,6 @@ declare global { interface Window {} } -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - function isTerminalState(value: unknown): value is TerminalState { return ( typeof value === "object" && @@ -183,30 +141,6 @@ function isTerminalState(value: unknown): value is TerminalState { ); } -function ensureTerminalScrollbarStyle(): void { - if (typeof document === "undefined") { - return; - } - if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) { - return; - } - - const styleElement = document.createElement("style"); - styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID; - styleElement.textContent = ` - [data-terminal-scrollbar-root="true"] .xterm-viewport { - scrollbar-width: none; - -ms-overflow-style: none; - } - - [data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar { - width: 0; - height: 0; - } - `; - document.head.appendChild(styleElement); -} - export default function TerminalEmulator({ ref, streamKey, @@ -246,13 +180,6 @@ export default function TerminalEmulator({ scrollbackLinesRef.current = scrollbackLines; fontFamilyRef.current = fontFamily; fontSizeRef.current = fontSize; - const viewportRef = useRef(null); - const dragStartOffsetRef = useRef(0); - const dragStartClientYRef = useRef(0); - const scrollVisibilityTimeoutRef = useRef | null>(null); - const scrollActiveTimeoutRef = useRef | null>(null); - const lastObservedOffsetRef = useRef(null); - const lastMetricsRef = useRef({ offset: 0, viewportSize: 0, contentSize: 0 }); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); const xtermThemeRef = useRef(xtermTheme); xtermThemeRef.current = xtermTheme; @@ -280,30 +207,8 @@ export default function TerminalEmulator({ initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; - const [viewportMetrics, setViewportMetrics] = useState({ - offset: 0, - viewportSize: 0, - contentSize: 0, - }); - const [isHandleHovered, setIsHandleHovered] = useState(false); - const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false); - const [isScrollVisible, setIsScrollVisible] = useState(false); - const [isScrollActive, setIsScrollActive] = useState(false); const [isDropActive, setIsDropActive] = useState(false); const dropActiveTimeoutRef = useRef | null>(null); - const updateViewportMetricsState = useCallback((metrics: ViewportMetrics) => { - const lastMetrics = lastMetricsRef.current; - if ( - metrics.offset === lastMetrics.offset && - metrics.viewportSize === lastMetrics.viewportSize && - metrics.contentSize === lastMetrics.contentSize - ) { - return; - } - - lastMetricsRef.current = metrics; - setViewportMetrics(metrics); - }, []); const domBridgeRef = useRef(null); useDOMImperativeHandle( @@ -366,10 +271,6 @@ export default function TerminalEmulator({ runtimeRef.current?.setScrollback({ lines: scrollbackLines }); }, [scrollbackLines]); - useEffect(() => { - ensureTerminalScrollbarStyle(); - }, []); - useEffect(() => { const root = rootRef.current; if (!root || !swipeGesturesEnabled) { @@ -580,188 +481,6 @@ export default function TerminalEmulator({ runtimeRef.current?.resize({ force: true, shouldClaim: true }); }, [resizeRequestToken]); - useEffect(() => { - const host = hostRef.current; - if (!host) { - return () => {}; - } - - const viewportElement = host.querySelector(".xterm-viewport"); - if (!viewportElement) { - viewportRef.current = null; - updateViewportMetricsState({ offset: 0, viewportSize: 0, contentSize: 0 }); - return () => {}; - } - - viewportRef.current = viewportElement; - - const updateViewportMetrics = () => { - const offset = Math.max(0, viewportElement.scrollTop); - const viewportSize = Math.max(0, viewportElement.clientHeight); - const contentSize = Math.max(0, viewportElement.scrollHeight); - updateViewportMetricsState({ offset, viewportSize, contentSize }); - }; - - updateViewportMetrics(); - - let scrollRafId: number | null = null; - const handleViewportScroll = () => { - if (scrollRafId !== null) { - return; - } - scrollRafId = requestAnimationFrame(() => { - scrollRafId = null; - updateViewportMetrics(); - }); - }; - - const resizeObserver = new ResizeObserver(() => { - updateViewportMetrics(); - }); - resizeObserver.observe(viewportElement); - const scrollAreaElement = host.querySelector(".xterm-scroll-area"); - if (scrollAreaElement) { - resizeObserver.observe(scrollAreaElement); - } - - viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true }); - - return () => { - if (scrollRafId !== null) { - cancelAnimationFrame(scrollRafId); - scrollRafId = null; - } - viewportElement.removeEventListener("scroll", handleViewportScroll); - resizeObserver.disconnect(); - if (viewportRef.current === viewportElement) { - viewportRef.current = null; - } - }; - }, [streamKey, updateViewportMetricsState]); - - useEffect(() => { - const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize); - const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset); - if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) { - setIsScrollVisible(false); - setIsScrollActive(false); - lastObservedOffsetRef.current = null; - return; - } - - const previousOffset = lastObservedOffsetRef.current; - lastObservedOffsetRef.current = normalizedOffset; - if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) { - return; - } - - setIsScrollVisible(true); - if (scrollVisibilityTimeoutRef.current !== null) { - clearTimeout(scrollVisibilityTimeoutRef.current); - } - scrollVisibilityTimeoutRef.current = setTimeout(() => { - setIsScrollVisible(false); - scrollVisibilityTimeoutRef.current = null; - }, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS); - - setIsScrollActive(true); - if (scrollActiveTimeoutRef.current !== null) { - clearTimeout(scrollActiveTimeoutRef.current); - } - scrollActiveTimeoutRef.current = setTimeout(() => { - setIsScrollActive(false); - scrollActiveTimeoutRef.current = null; - }, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS); - }, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]); - - useEffect(() => { - return () => { - if (scrollVisibilityTimeoutRef.current !== null) { - clearTimeout(scrollVisibilityTimeoutRef.current); - } - if (scrollActiveTimeoutRef.current !== null) { - clearTimeout(scrollActiveTimeoutRef.current); - } - }; - }, []); - - const scrollbarGeometry = useMemo( - () => - computeVerticalScrollbarGeometry({ - viewportSize: viewportMetrics.viewportSize, - contentSize: viewportMetrics.contentSize, - offset: viewportMetrics.offset, - }), - [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize], - ); - - useEffect(() => { - if (!isDraggingScrollbar) { - return () => {}; - } - - const handlePointerMove = (event: PointerEvent) => { - const dragDelta = event.clientY - dragStartClientYRef.current; - const nextOffset = computeScrollOffsetFromDragDelta({ - startOffset: dragStartOffsetRef.current, - dragDelta, - maxScrollOffset: scrollbarGeometry.maxScrollOffset, - maxHandleOffset: scrollbarGeometry.maxHandleOffset, - }); - const viewportElement = viewportRef.current; - if (!viewportElement) { - return; - } - viewportElement.scrollTop = nextOffset; - updateViewportMetricsState({ - offset: nextOffset, - viewportSize: Math.max(0, viewportElement.clientHeight), - contentSize: Math.max(0, viewportElement.scrollHeight), - }); - }; - - const stopDragging = () => { - setIsDraggingScrollbar(false); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", stopDragging); - window.addEventListener("pointercancel", stopDragging); - - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", stopDragging); - window.removeEventListener("pointercancel", stopDragging); - }; - }, [ - isDraggingScrollbar, - scrollbarGeometry.maxHandleOffset, - scrollbarGeometry.maxScrollOffset, - updateViewportMetricsState, - ]); - - const handleVisible = - scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered); - let handleOpacity: number; - if (isDraggingScrollbar) handleOpacity = SCROLLBAR_HANDLE_OPACITY_DRAGGING; - else if (isHandleHovered) handleOpacity = SCROLLBAR_HANDLE_OPACITY_HOVERED; - else if (isScrollVisible) handleOpacity = SCROLLBAR_HANDLE_OPACITY_VISIBLE; - else handleOpacity = 0; - const handleWidth = - isDraggingScrollbar || isHandleHovered - ? SCROLLBAR_HANDLE_WIDTH_ACTIVE - : SCROLLBAR_HANDLE_WIDTH_IDLE; - const thumbRegionOffset = Math.max( - 0, - scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING, - ); - const thumbRegionHeight = Math.min( - viewportMetrics.viewportSize - thumbRegionOffset, - scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2, - ); - const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2); - const handleTravelDurationMs = - isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS; const showTerminalContextMenu = useCallback(() => { const showContextMenu = window.paseoDesktop?.menu?.showContextMenu; if (typeof showContextMenu !== "function") { @@ -788,29 +507,6 @@ export default function TerminalEmulator({ [showTerminalContextMenu], ); - const scrollbarMaxOffset = scrollbarGeometry.maxScrollOffset; - const handleScrollbarPointerDown = useCallback( - (event: ReactPointerEvent) => { - event.preventDefault(); - event.stopPropagation(); - dragStartOffsetRef.current = clamp(viewportMetrics.offset, 0, scrollbarMaxOffset); - dragStartClientYRef.current = event.clientY; - setIsDraggingScrollbar(true); - }, - [scrollbarMaxOffset, viewportMetrics.offset], - ); - - const handleScrollbarPointerEnter = useCallback(() => { - if (!isScrollVisible && !isDraggingScrollbar) { - return; - } - setIsHandleHovered(true); - }, [isScrollVisible, isDraggingScrollbar]); - - const handleScrollbarPointerLeave = useCallback(() => { - setIsHandleHovered(false); - }, []); - const clearDropActiveTimeout = useCallback(() => { if (dropActiveTimeoutRef.current === null) { return; @@ -944,46 +640,6 @@ export default function TerminalEmulator({ }), [isDropActive], ); - const handleContainerStyle = useMemo( - () => ({ - position: "absolute", - top: 0, - right: -3, - width: SCROLLBAR_HANDLE_GRAB_WIDTH, - height: thumbRegionHeight, - transform: `translateY(${thumbRegionOffset}px)`, - cursor: isDraggingScrollbar ? "grabbing" : "grab", - touchAction: "none", - userSelect: "none", - transitionProperty: "transform", - transitionDuration: `${handleTravelDurationMs}ms`, - transitionTimingFunction: "linear", - pointerEvents: handleVisible ? "auto" : "none", - }), - [ - thumbRegionHeight, - thumbRegionOffset, - isDraggingScrollbar, - handleTravelDurationMs, - handleVisible, - ], - ); - const handleInnerStyle = useMemo( - () => ({ - marginTop: handleInsetTop, - height: scrollbarGeometry.handleSize, - width: handleWidth, - borderRadius: 999, - alignSelf: "center", - backgroundColor: "rgba(113, 113, 122, 1)", - opacity: handleOpacity, - transitionProperty: "opacity, width, background-color", - transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`, - transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", - }), - [handleInsetTop, scrollbarGeometry.handleSize, handleWidth, handleOpacity], - ); - return (
- {scrollbarGeometry.isVisible ? ( -
-
-
-
-
- ) : null}
); } diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a77156478..b3e6cd463 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -15,7 +15,6 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { highlightDiffLines } from "@/utils/diff-highlight"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache"; @@ -46,7 +45,6 @@ interface DetailStyles { jsonScrollErrorCombined: StyleProp; fullBleedContainerStyle: StyleProp; loadingContainerStyle: StyleProp; - webScrollbarStyle: StyleProp; resolvedMaxHeight: number | undefined; shouldFill: boolean; isFullBleed: boolean; @@ -70,7 +68,6 @@ function useDetailStyles( resolvedMaxHeight: number | undefined, fillAvailableHeight: boolean, ): DetailStyles { - const webScrollbarStyle = useWebScrollbarStyle(); const isFullBleed = resolveIsFullBleed(detail); const shouldFill = resolveShouldFill(detail, fillAvailableHeight); const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; @@ -88,35 +85,26 @@ function useDetailStyles( styles.codeVerticalScroll, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, - webScrollbarStyle, ], - [resolvedMaxHeight, shouldFill, webScrollbarStyle], + [resolvedMaxHeight, shouldFill], ); const scrollAreaFillStyle = useMemo( () => [ styles.scrollArea, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, - webScrollbarStyle, ], - [resolvedMaxHeight, shouldFill, webScrollbarStyle], + [resolvedMaxHeight, shouldFill], ); const scrollAreaStyle = useMemo( () => [ styles.scrollArea, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), - webScrollbarStyle, ], - [resolvedMaxHeight, webScrollbarStyle], - ); - const jsonScrollCombined = useMemo( - () => [styles.jsonScroll, webScrollbarStyle], - [webScrollbarStyle], - ); - const jsonScrollErrorCombined = useMemo( - () => [styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle], - [webScrollbarStyle], + [resolvedMaxHeight], ); + const jsonScrollCombined = styles.jsonScroll; + const jsonScrollErrorCombined = [styles.jsonScroll, styles.jsonScrollError]; const fullBleedContainerStyle = useMemo( () => [ isFullBleed ? styles.fullBleedContainer : styles.paddedContainer, @@ -139,7 +127,6 @@ function useDetailStyles( jsonScrollErrorCombined, fullBleedContainerStyle, loadingContainerStyle, - webScrollbarStyle, resolvedMaxHeight, shouldFill, isFullBleed, @@ -179,7 +166,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -224,7 +210,6 @@ function WorktreeSetupDetailSection({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -389,7 +374,6 @@ function SubAgentDetailSection({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -466,12 +450,7 @@ function ScrollableTextSection({ nestedScrollEnabled showsVerticalScrollIndicator={true} > - + {keyedLines ? ( ) : ( @@ -501,12 +480,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) { nestedScrollEnabled showsVerticalScrollIndicator > - + {result ? `${url}\n\n${result}` : url} @@ -545,12 +519,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[ nestedScrollEnabled showsVerticalScrollIndicator > - + {detail.content} diff --git a/packages/app/src/components/tool-call-group.tsx b/packages/app/src/components/tool-call-group.tsx deleted file mode 100644 index ef44e3f7a..000000000 --- a/packages/app/src/components/tool-call-group.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { memo, useCallback, useMemo, type ReactNode } from "react"; -import { - ActivityIndicator, - Pressable, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; -import { useTranslation } from "react-i18next"; -import { ChevronRight, TriangleAlert, Wrench } from "lucide-react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import type { - CompactToolCallGroup as CompactToolCallGroupModel, - ToolCallCategorySummary, -} from "@/tool-calls/grouping"; -import { componentForToolCallIcon } from "@/utils/tool-call-icon"; - -interface ToolCallGroupProps { - group: CompactToolCallGroupModel; - presentation: "overview" | "concise"; - expanded: boolean; - onExpandedChange: (groupId: string, expanded: boolean) => void; - children: ReactNode; -} - -function CategoryStatus({ category }: { category: ToolCallCategorySummary }) { - const { t } = useTranslation(); - if (category.failedCount > 0) { - return ( - - - - {t("toolCallGroup.failed", { count: category.failedCount })} - - - ); - } - if (category.runningCount > 0) { - return ; - } - return null; -} - -function CategoryRow({ - category, - resourceLimit, -}: { - category: ToolCallCategorySummary; - resourceLimit: number; -}) { - const Icon = componentForToolCallIcon(category.iconName); - const visibleResources = category.resources.slice(0, resourceLimit); - const hiddenResourceCount = category.resources.length - visibleResources.length; - const resourceText = [ - ...visibleResources, - ...(hiddenResourceCount > 0 ? [`+${hiddenResourceCount}`] : []), - ].join(", "); - - return ( - - - - - ×{category.callCount} - {category.label} - - {resourceText ? ( - - {resourceText} - - ) : null} - - ); -} - -function GroupHeaderIcon({ - group, - compact, -}: { - group: CompactToolCallGroupModel; - compact: boolean; -}) { - const size = compact ? 11 : 12; - if (group.failedCount > 0) { - return ; - } - if (group.isRunning) { - return ; - } - return ; -} - -function joinSummaryParts(parts: string[], conjunction: string): string { - if (parts.length === 0) { - return ""; - } - let joined: string; - if (parts.length === 1) { - joined = parts[0] ?? ""; - } else if (parts.length === 2) { - joined = `${parts[0]} ${conjunction} ${parts[1]}`; - } else { - joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; - } - const firstCharacter = joined[0]; - return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; -} - -export const ToolCallGroup = memo(function ToolCallGroup({ - group, - presentation, - expanded, - onExpandedChange, - children, -}: ToolCallGroupProps) { - const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); - const isOverview = presentation === "overview"; - const resourceLimit = isCompact ? 2 : 3; - const handlePress = useCallback( - () => onExpandedChange(group.id, !expanded), - [expanded, group.id, onExpandedChange], - ); - const accessibilityState = useMemo(() => ({ expanded }), [expanded]); - const headerStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.header, - !isOverview && styles.headerConcise, - (pressed || hovered || expanded) && styles.headerActive, - ], - [expanded, isOverview], - ); - const summary = useMemo(() => { - const parts: string[] = []; - if (group.editedFileCount > 0) { - parts.push( - t( - group.editedFileCount === 1 - ? "toolCallGroup.editedFiles.one" - : "toolCallGroup.editedFiles.other", - { count: group.editedFileCount }, - ), - ); - } - if (group.commandCount > 0) { - parts.push( - t( - group.commandCount === 1 ? "toolCallGroup.commands.one" : "toolCallGroup.commands.other", - { count: group.commandCount }, - ), - ); - } - if (group.readFileCount > 0) { - parts.push( - t( - group.readFileCount === 1 - ? "toolCallGroup.readFiles.one" - : "toolCallGroup.readFiles.other", - { count: group.readFileCount }, - ), - ); - } - if (group.searchCount > 0) { - parts.push( - t(group.searchCount === 1 ? "toolCallGroup.searches.one" : "toolCallGroup.searches.other", { - count: group.searchCount, - }), - ); - } - if (group.otherToolCount > 0) { - parts.push( - t( - group.otherToolCount === 1 - ? "toolCallGroup.otherTools.one" - : "toolCallGroup.otherTools.other", - { count: group.otherToolCount }, - ), - ); - } - if (group.paseoCallCount > 0) { - parts.push( - t( - group.paseoCallCount === 1 - ? "toolCallGroup.paseoCalls.one" - : "toolCallGroup.paseoCalls.other", - { count: group.paseoCallCount }, - ), - ); - } - return joinSummaryParts(parts, t("toolCallGroup.and")); - }, [group, t]); - const accessibilityLabel = isOverview - ? summary - : t("toolCallGroup.accessibilityLabel", { count: group.callCount }); - - return ( - - - - - - {isOverview ? ( - - {summary} - - ) : ( - <> - {t("toolCallGroup.title")} - ×{group.callCount} - - )} - {group.failedCount > 0 ? ( - - {t("toolCallGroup.failed", { count: group.failedCount })} - - ) : null} - - - - {expanded ? {children} : null} - {!expanded && !isOverview ? ( - - {group.categories.map((category) => ( - - ))} - - ) : null} - - ); -}); - -const styles = StyleSheet.create((theme) => ({ - container: { - marginHorizontal: -theme.spacing[3], - }, - header: { - minHeight: 26, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.lg, - }, - headerActive: { - backgroundColor: theme.colors.surface1, - }, - headerConcise: { - minHeight: 30, - }, - headerIcon: { - width: 18, - height: 18, - alignItems: "center", - justifyContent: "center", - }, - summary: { - flex: 1, - minWidth: 0, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - conciseTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - conciseCallCount: { - flex: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - categories: { - gap: theme.spacing[1], - paddingLeft: theme.spacing[8], - paddingRight: theme.spacing[2], - paddingTop: theme.spacing[1], - }, - categoryRow: { - minHeight: 20, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - categoryIcon: { - width: 14, - alignItems: "center", - }, - categoryLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - minWidth: 64, - }, - categoryCount: { - width: theme.spacing[6], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - textAlign: "right", - }, - categoryStatus: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - resources: { - flex: 1, - minWidth: 0, - color: theme.colors.foregroundMuted, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - }, - expandedCalls: { - paddingTop: theme.spacing[1], - marginHorizontal: theme.spacing[3], - }, - chevronExpanded: { - transform: [{ rotate: "90deg" }], - }, - foreground: { - color: theme.colors.foreground, - }, - muted: { - color: theme.colors.foregroundMuted, - }, - error: { - color: theme.colors.destructive, - fontSize: theme.fontSize.xs, - }, -})); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index ea6ba574f..1869d8568 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -40,7 +40,6 @@ import { } from "@/components/ui/isolated-bottom-sheet-modal"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { isWeb, isNative } from "@/constants/platform"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -389,7 +388,6 @@ export function ContextMenuContent({ const { t } = useTranslation(); const context = useContextMenuContext("ContextMenuContent"); const { theme } = useUnistyles(); - const webScrollbarStyle = useWebScrollbarStyle(); const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; @@ -579,7 +577,6 @@ export function ContextMenuContent({ {children} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 9af347498..cccafc03c 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -31,7 +31,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { isWeb } from "@/constants/platform"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; @@ -448,7 +447,6 @@ export function DropdownMenuContent({ useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); const surfaceNativeID = useId(); - const webScrollbarStyle = useWebScrollbarStyle(); const [closing, setClosing] = useState(false); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState(null); @@ -603,8 +601,8 @@ export function DropdownMenuContent({ align, ]); const scrollViewportStyle = useMemo( - () => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null], - [visibleContentSize, webScrollbarStyle], + () => [visibleContentSize ? { height: visibleContentSize.height } : null], + [visibleContentSize], ); if (!modalVisible) return null; @@ -675,10 +673,12 @@ export function DropdownMenuSeparator({ export function DropdownMenuHint({ children, + style, testID, -}: PropsWithChildren<{ testID?: string }>): ReactElement { +}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { + const hintContainerStyle = useMemo(() => [styles.hintContainer, style], [style]); return ( - + {children} ); @@ -908,7 +908,7 @@ const styles = StyleSheet.create((theme) => ({ }, hintContainer: { paddingHorizontal: theme.spacing[3], - paddingBottom: theme.spacing[2], + paddingVertical: theme.spacing[2], }, hintText: { fontSize: theme.fontSize.xs, diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx deleted file mode 100644 index ab6e13e6e..000000000 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; -import { - type FlatList, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, - type ScrollView, -} from "react-native"; -import { - WebDesktopScrollbarOverlay, - useWebDesktopScrollbarMetrics, - type ScrollbarMetrics, -} from "./web-desktop-scrollbar"; -import { isWeb as platformIsWeb } from "@/constants/platform"; - -const METRICS_EPSILON = 0.5; -const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; - -function ensureHideScrollbarStyle(): void { - if (typeof document === "undefined") return; - if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return; - const style = document.createElement("style"); - style.id = HIDE_SCROLLBAR_STYLE_ID; - style.textContent = ` - [data-hide-scrollbar] { - scrollbar-width: none; - -ms-overflow-style: none; - scrollbar-gutter: auto; - } - - [data-hide-scrollbar]::-webkit-scrollbar { - display: none; - width: 0; - height: 0; - } - - [data-hide-scrollbar]::-webkit-scrollbar-button { - display: none; - width: 0; - height: 0; - } - `; - document.head.appendChild(style); -} - -function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean { - return ( - Math.abs(a.offset - b.offset) > METRICS_EPSILON || - Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON || - Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON - ); -} - -// ── DOM element scrollbar ──────────────────────────────────────────── -// Fully automatic: listens to scroll/input/resize events on the element, -// hides the native scrollbar, and returns a themed overlay or null. - -export function useWebElementScrollbar( - elementRef: RefObject, - options?: { - enabled?: boolean; - contentRef?: RefObject; - }, -): ReactNode { - const enabled = (options?.enabled ?? true) && platformIsWeb; - const contentRef = options?.contentRef; - - const [metrics, setMetrics] = useState({ - offset: 0, - viewportSize: 0, - contentSize: 0, - }); - - useLayoutEffect(() => { - if (!enabled) return; - const element = elementRef.current; - if (!element) return; - - type ScrollbarStyle = CSSStyleDeclaration & { - scrollbarWidth: string; - msOverflowStyle: string; - scrollbarGutter: string; - }; - const style = element.style as ScrollbarStyle; - const previousScrollbarWidth = style.scrollbarWidth; - const previousMsOverflowStyle = style.msOverflowStyle; - const previousScrollbarGutter = style.scrollbarGutter; - - element.setAttribute("data-hide-scrollbar", ""); - style.scrollbarWidth = "none"; - style.msOverflowStyle = "none"; - style.scrollbarGutter = "auto"; - ensureHideScrollbarStyle(); - - function update() { - const el = elementRef.current; - if (!el) return; - const next: ScrollbarMetrics = { - offset: el.scrollTop, - viewportSize: el.clientHeight, - contentSize: el.scrollHeight, - }; - setMetrics((prev) => (metricsChanged(prev, next) ? next : prev)); - } - - element.addEventListener("scroll", update, { passive: true }); - - const resizeObserver = new ResizeObserver(update); - resizeObserver.observe(element); - const contentElement = contentRef?.current; - if (contentElement) { - resizeObserver.observe(contentElement); - } - - update(); - - return () => { - element.removeEventListener("scroll", update); - resizeObserver.disconnect(); - element.removeAttribute("data-hide-scrollbar"); - style.scrollbarWidth = previousScrollbarWidth; - style.msOverflowStyle = previousMsOverflowStyle; - style.scrollbarGutter = previousScrollbarGutter; - }; - }, [contentRef, elementRef, enabled]); - - const onScrollToOffset = useCallback( - (offset: number) => { - elementRef.current?.scrollTo({ top: offset, behavior: "auto" }); - }, - [elementRef], - ); - - if (!enabled) return null; - - return ( - - ); -} - -// ── RN ScrollView / FlatList scrollbar ─────────────────────────────── -// Returns event handlers to wire onto your ScrollView/FlatList plus -// a renderable overlay. The overlay is null when disabled. - -interface WebScrollViewScrollbar { - onScroll: (event: NativeSyntheticEvent) => void; - onLayout: (event: LayoutChangeEvent) => void; - onContentSizeChange: (width: number, height: number) => void; - overlay: ReactNode; -} - -export function useWebScrollViewScrollbar( - scrollableRef: RefObject, - options?: { enabled?: boolean }, -): WebScrollViewScrollbar { - const enabled = (options?.enabled ?? true) && platformIsWeb; - const metricsHook = useWebDesktopScrollbarMetrics(); - - const onScrollToOffset = useCallback( - (offset: number) => { - const scrollable = scrollableRef.current; - if (!scrollable) return; - if ("scrollToOffset" in scrollable) { - scrollable.scrollToOffset({ offset, animated: false }); - } else { - scrollable.scrollTo({ y: offset, animated: false }); - } - }, - [scrollableRef], - ); - - const overlay: ReactNode = enabled ? ( - - ) : null; - - return { - onScroll: metricsHook.onScroll, - onLayout: metricsHook.onLayout, - onContentSizeChange: metricsHook.onContentSizeChange, - overlay, - }; -} diff --git a/packages/app/src/components/web-desktop-scrollbar.math.ts b/packages/app/src/components/web-desktop-scrollbar.math.ts deleted file mode 100644 index cdf155d7a..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.math.ts +++ /dev/null @@ -1,73 +0,0 @@ -const DEFAULT_MIN_HANDLE_SIZE = 36; - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -export interface VerticalScrollbarGeometryInput { - viewportSize: number; - contentSize: number; - offset: number; - minHandleSize?: number; -} - -export interface VerticalScrollbarGeometry { - isVisible: boolean; - maxScrollOffset: number; - handleSize: number; - handleOffset: number; - maxHandleOffset: number; -} - -export function computeVerticalScrollbarGeometry( - input: VerticalScrollbarGeometryInput, -): VerticalScrollbarGeometry { - const viewportSize = Number.isFinite(input.viewportSize) ? Math.max(0, input.viewportSize) : 0; - const contentSize = Number.isFinite(input.contentSize) ? Math.max(0, input.contentSize) : 0; - const minHandleSize = Number.isFinite(input.minHandleSize) - ? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE) - : DEFAULT_MIN_HANDLE_SIZE; - - const maxScrollOffset = Math.max(0, contentSize - viewportSize); - if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) { - return { - isVisible: false, - maxScrollOffset: 0, - handleSize: 0, - handleOffset: 0, - maxHandleOffset: 0, - }; - } - - const rawHandleSize = (viewportSize * viewportSize) / contentSize; - const handleSize = clamp(rawHandleSize, minHandleSize, viewportSize); - const maxHandleOffset = Math.max(0, viewportSize - handleSize); - const clampedOffset = clamp(input.offset, 0, maxScrollOffset); - const handleOffset = - maxScrollOffset > 0 ? (clampedOffset / maxScrollOffset) * maxHandleOffset : 0; - - return { - isVisible: true, - maxScrollOffset, - handleSize, - handleOffset, - maxHandleOffset, - }; -} - -export interface ScrollOffsetFromDragDeltaInput { - startOffset: number; - dragDelta: number; - maxScrollOffset: number; - maxHandleOffset: number; -} - -export function computeScrollOffsetFromDragDelta(input: ScrollOffsetFromDragDeltaInput): number { - if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) { - return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset)); - } - - const scrollPerPixel = input.maxScrollOffset / input.maxHandleOffset; - const nextOffset = input.startOffset + input.dragDelta * scrollPerPixel; - return clamp(nextOffset, 0, input.maxScrollOffset); -} diff --git a/packages/app/src/components/web-desktop-scrollbar.test.ts b/packages/app/src/components/web-desktop-scrollbar.test.ts deleted file mode 100644 index 91bcafbab..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - computeScrollOffsetFromDragDelta, - computeVerticalScrollbarGeometry, -} from "./web-desktop-scrollbar.math"; - -describe("computeVerticalScrollbarGeometry", () => { - it("returns hidden geometry when content does not overflow", () => { - const geometry = computeVerticalScrollbarGeometry({ - viewportSize: 500, - contentSize: 500, - offset: 0, - minHandleSize: 36, - }); - - expect(geometry).toEqual({ - isVisible: false, - maxScrollOffset: 0, - handleSize: 0, - handleOffset: 0, - maxHandleOffset: 0, - }); - }); - - it("computes visible geometry when content overflows", () => { - const geometry = computeVerticalScrollbarGeometry({ - viewportSize: 500, - contentSize: 2000, - offset: 375, - minHandleSize: 36, - }); - - expect(geometry).toEqual({ - isVisible: true, - maxScrollOffset: 1500, - handleSize: 125, - handleOffset: 93.75, - maxHandleOffset: 375, - }); - }); - - it("clamps handle size to min and offset to bounds", () => { - const geometry = computeVerticalScrollbarGeometry({ - viewportSize: 100, - contentSize: 10000, - offset: 99999, - minHandleSize: 24, - }); - - expect(geometry).toEqual({ - isVisible: true, - maxScrollOffset: 9900, - handleSize: 24, - handleOffset: 76, - maxHandleOffset: 76, - }); - }); -}); - -describe("computeScrollOffsetFromDragDelta", () => { - it("maps drag distance proportionally to scroll offset", () => { - const nextOffset = computeScrollOffsetFromDragDelta({ - startOffset: 250, - dragDelta: 50, - maxScrollOffset: 1000, - maxHandleOffset: 200, - }); - - expect(nextOffset).toBe(500); - }); - - it("clamps to scroll bounds", () => { - const nextOffset = computeScrollOffsetFromDragDelta({ - startOffset: 900, - dragDelta: 1000, - maxScrollOffset: 1000, - maxHandleOffset: 200, - }); - - expect(nextOffset).toBe(1000); - }); -}); diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx deleted file mode 100644 index caff521bc..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ /dev/null @@ -1,464 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - PanResponder, - type GestureResponderEvent, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, - type ViewStyle, - View, -} from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { isWeb as platformIsWeb } from "@/constants/platform"; -import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; -import { - computeScrollOffsetFromDragDelta, - computeVerticalScrollbarGeometry, -} from "./web-desktop-scrollbar.math"; - -const METRICS_EPSILON = 0.5; -const HANDLE_WIDTH_IDLE = 6; -const HANDLE_WIDTH_ACTIVE = 9; -const HANDLE_GRAB_WIDTH = 18; -const HANDLE_GRAB_VERTICAL_PADDING = 8; -const HANDLE_OPACITY_VISIBLE = 0.62; -const HANDLE_OPACITY_HOVERED = 0.78; -const HANDLE_OPACITY_DRAGGING = 0.9; -const HANDLE_TRAVEL_TRANSITION_DURATION_MS = 90; -const HANDLE_FADE_DURATION_MS = 220; -const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240; -const HANDLE_SCROLL_VISIBILITY_MS = 1200; -const HANDLE_SCROLL_ACTIVE_MS = 110; - -interface WebPointerStyle { - cursor?: "grab" | "grabbing"; - touchAction?: "none"; - userSelect?: "none"; - transitionProperty?: string; - transitionDuration?: string; - transitionTimingFunction?: string; -} - -interface PointerLikeEvent { - clientY?: number; - pageY?: number; - nativeEvent?: { clientY?: number; pageY?: number; preventDefault?: () => void }; - preventDefault?: () => void; - stopPropagation?: () => void; -} - -function readClientY(event: PointerLikeEvent): number | null { - const value = - event?.nativeEvent?.clientY ?? event?.clientY ?? event?.nativeEvent?.pageY ?? event?.pageY; - return typeof value === "number" ? value : null; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -export interface ScrollbarMetrics { - offset: number; - viewportSize: number; - contentSize: number; -} - -function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean { - return ( - Math.abs(a.offset - b.offset) <= METRICS_EPSILON && - Math.abs(a.viewportSize - b.viewportSize) <= METRICS_EPSILON && - Math.abs(a.contentSize - b.contentSize) <= METRICS_EPSILON - ); -} - -interface WebDesktopScrollbarOverlayProps { - enabled: boolean; - metrics: ScrollbarMetrics; - onScrollToOffset: (offset: number) => void; - inverted?: boolean; -} - -export function useWebDesktopScrollbarMetrics() { - const [metrics, setMetrics] = useState({ - offset: 0, - viewportSize: 0, - contentSize: 0, - }); - - const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => { - setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next)); - }, []); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent; - setMetricsIfChanged({ - offset: Math.max(0, contentOffset.y), - viewportSize: Math.max(0, layoutMeasurement.height), - contentSize: Math.max(0, contentSize.height), - }); - }, - [setMetricsIfChanged], - ); - - const onLayout = useCallback((event: LayoutChangeEvent) => { - const viewportSize = Math.max(0, event.nativeEvent.layout.height); - setMetrics((previous) => { - const next = { ...previous, viewportSize }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - const onContentSizeChange = useCallback((_width: number, height: number) => { - const contentSize = Math.max(0, height); - setMetrics((previous) => { - const next = { ...previous, contentSize }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - const setOffset = useCallback((offset: number) => { - const clampedOffset = Math.max(0, offset); - setMetrics((previous) => { - const next = { ...previous, offset: clampedOffset }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - return { - ...metrics, - onScroll, - onLayout, - onContentSizeChange, - setOffset, - }; -} - -export function WebDesktopScrollbarOverlay({ - enabled, - metrics, - onScrollToOffset, - inverted = false, -}: WebDesktopScrollbarOverlayProps) { - const { theme } = useUnistyles(); - const [isHandleHovered, setIsHandleHovered] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [isScrollVisible, setIsScrollVisible] = useState(false); - const [isScrollActive, setIsScrollActive] = useState(false); - const dragStartOffsetRef = useRef(0); - const dragStartClientYRef = useRef(0); - const scrollVisibilityTimeoutRef = useRef | null>(null); - const scrollActiveTimeoutRef = useRef | null>(null); - const lastObservedOffsetRef = useRef(null); - const geometryRef = useRef({ - maxHandleOffset: 0, - maxScrollOffset: 0, - }); - const onScrollToOffsetRef = useRef(onScrollToOffset); - - const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); - const normalizedOffset = inverted - ? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset)) - : clamp(metrics.offset, 0, maxScrollOffset); - const normalizedOffsetRef = useRef(normalizedOffset); - - const geometry = useMemo( - () => - computeVerticalScrollbarGeometry({ - viewportSize: metrics.viewportSize, - contentSize: metrics.contentSize, - offset: normalizedOffset, - }), - [metrics.contentSize, metrics.viewportSize, normalizedOffset], - ); - - useEffect(() => { - geometryRef.current = { - maxHandleOffset: geometry.maxHandleOffset, - maxScrollOffset: geometry.maxScrollOffset, - }; - }, [geometry.maxHandleOffset, geometry.maxScrollOffset]); - - useEffect(() => { - onScrollToOffsetRef.current = onScrollToOffset; - }, [onScrollToOffset]); - - useEffect(() => { - normalizedOffsetRef.current = normalizedOffset; - }, [normalizedOffset]); - - const clearScrollVisibilityTimeout = useCallback(() => { - if (scrollVisibilityTimeoutRef.current === null) { - return; - } - clearTimeout(scrollVisibilityTimeoutRef.current); - scrollVisibilityTimeoutRef.current = null; - }, []); - - const clearScrollActiveTimeout = useCallback(() => { - if (scrollActiveTimeoutRef.current === null) { - return; - } - clearTimeout(scrollActiveTimeoutRef.current); - scrollActiveTimeoutRef.current = null; - }, []); - - const revealScrollbarFromScroll = useCallback(() => { - setIsScrollVisible(true); - clearScrollVisibilityTimeout(); - scrollVisibilityTimeoutRef.current = setTimeout(() => { - setIsScrollVisible(false); - scrollVisibilityTimeoutRef.current = null; - }, HANDLE_SCROLL_VISIBILITY_MS); - }, [clearScrollVisibilityTimeout]); - - const markScrollActivity = useCallback(() => { - setIsScrollActive(true); - clearScrollActiveTimeout(); - scrollActiveTimeoutRef.current = setTimeout(() => { - setIsScrollActive(false); - scrollActiveTimeoutRef.current = null; - }, HANDLE_SCROLL_ACTIVE_MS); - }, [clearScrollActiveTimeout]); - - useEffect(() => { - if (!enabled || !geometry.isVisible) { - setIsScrollVisible(false); - setIsScrollActive(false); - clearScrollVisibilityTimeout(); - clearScrollActiveTimeout(); - lastObservedOffsetRef.current = null; - return; - } - - const previousOffset = lastObservedOffsetRef.current; - lastObservedOffsetRef.current = normalizedOffset; - if (previousOffset === null) { - return; - } - if (Math.abs(normalizedOffset - previousOffset) <= METRICS_EPSILON) { - return; - } - revealScrollbarFromScroll(); - markScrollActivity(); - }, [ - clearScrollActiveTimeout, - clearScrollVisibilityTimeout, - enabled, - geometry.isVisible, - markScrollActivity, - normalizedOffset, - revealScrollbarFromScroll, - ]); - - useEffect( - () => () => { - clearScrollActiveTimeout(); - clearScrollVisibilityTimeout(); - }, - [clearScrollActiveTimeout, clearScrollVisibilityTimeout], - ); - - const applyDragDelta = useCallback( - (dragDelta: number) => { - const currentGeometry = geometryRef.current; - const nextNormalizedOffset = computeScrollOffsetFromDragDelta({ - startOffset: dragStartOffsetRef.current, - dragDelta, - maxScrollOffset: currentGeometry.maxScrollOffset, - maxHandleOffset: currentGeometry.maxHandleOffset, - }); - const nextOffset = inverted - ? currentGeometry.maxScrollOffset - nextNormalizedOffset - : nextNormalizedOffset; - onScrollToOffsetRef.current(nextOffset); - }, - [inverted], - ); - - const panResponder = useMemo(() => { - if (platformIsWeb) { - return null; - } - - return PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: () => true, - onPanResponderTerminationRequest: () => false, - onPanResponderGrant: (event: GestureResponderEvent) => { - const clientY = readClientY(event); - dragStartOffsetRef.current = normalizedOffsetRef.current; - if (clientY !== null) { - dragStartClientYRef.current = clientY; - } - setIsDragging(true); - }, - onPanResponderMove: (_event, gestureState) => { - applyDragDelta(gestureState.dy); - }, - onPanResponderRelease: () => { - setIsDragging(false); - }, - onPanResponderTerminate: () => { - setIsDragging(false); - }, - }); - }, [applyDragDelta]); - - const startWebDrag = useCallback((event: PointerLikeEvent) => { - if (!platformIsWeb) { - return; - } - const clientY = readClientY(event); - if (clientY === null) { - return; - } - event?.preventDefault?.(); - event?.stopPropagation?.(); - event?.nativeEvent?.preventDefault?.(); - dragStartOffsetRef.current = normalizedOffsetRef.current; - dragStartClientYRef.current = clientY; - setIsDragging(true); - }, []); - - const handleGrabHoverIn = useCallback(() => { - if (!isScrollVisible && !isDragging) { - return; - } - setIsHandleHovered(true); - }, [isDragging, isScrollVisible]); - - const handleGrabHoverOut = useCallback(() => { - setIsHandleHovered(false); - }, []); - - useEffect(() => { - if (!platformIsWeb || !isDragging) { - return; - } - - const handlePointerMove = (event: PointerEvent) => { - const dragDelta = event.clientY - dragStartClientYRef.current; - applyDragDelta(dragDelta); - }; - - const stopDragging = () => { - setIsDragging(false); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", stopDragging); - window.addEventListener("pointercancel", stopDragging); - - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", stopDragging); - window.removeEventListener("pointercancel", stopDragging); - }; - }, [applyDragDelta, isDragging]); - - const handleVisible = isDragging || isScrollVisible || isHandleHovered; - let handleOpacity: number; - if (isDragging) handleOpacity = HANDLE_OPACITY_DRAGGING; - else if (isHandleHovered) handleOpacity = HANDLE_OPACITY_HOVERED; - else if (isScrollVisible) handleOpacity = HANDLE_OPACITY_VISIBLE; - else handleOpacity = 0; - const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE; - const handleColor = theme.colors.scrollbarHandle; - const handleCursor = isDragging ? "grabbing" : "grab"; - const handleTravelDurationMs = - isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS; - const thumbRegionOffset = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING); - const thumbRegionHeight = Math.min( - metrics.viewportSize - thumbRegionOffset, - geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2, - ); - const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2); - - const thumbRegionStyle = useMemo( - () => [ - styles.thumbRegion, - inlineUnistylesStyle({ - height: thumbRegionHeight, - transform: [{ translateY: thumbRegionOffset }], - }), - platformIsWeb && - inlineUnistylesStyle({ - cursor: handleCursor, - touchAction: "none", - userSelect: "none", - transitionProperty: "transform", - transitionDuration: `${handleTravelDurationMs}ms`, - transitionTimingFunction: "linear", - } satisfies WebPointerStyle as unknown as ViewStyle), - ], - [thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs], - ); - - const handleStyle = useMemo( - () => [ - styles.handle, - inlineUnistylesStyle({ - marginTop: handleInsetTop, - height: geometry.handleSize, - width: handleWidth, - backgroundColor: handleColor, - opacity: handleOpacity, - }), - platformIsWeb && - inlineUnistylesStyle({ - transitionProperty: "opacity, width, background-color", - transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, - transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", - } satisfies WebPointerStyle as unknown as ViewStyle), - ], - [handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity], - ); - - if (!enabled || !geometry.isVisible) { - return null; - } - - return ( - - - - - - ); -} - -const styles = StyleSheet.create(() => ({ - overlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - width: 12, - alignItems: "center", - justifyContent: "flex-start", - zIndex: 10, - }, - handle: { - width: HANDLE_WIDTH_IDLE, - borderRadius: 999, - alignSelf: "center", - }, - thumbRegion: { - position: "absolute", - right: -3, - width: HANDLE_GRAB_WIDTH, - top: 0, - }, -})); diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index f6af2e78d..ad29711a5 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -227,6 +227,7 @@ describe("cancelComposerAgent", () => { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; + onCancelFailed: (error: unknown) => void; } { const canceledIds: string[] = []; return { @@ -240,6 +241,7 @@ describe("cancelComposerAgent", () => { isAgentRunning: true, isCancellingAgent: false, isConnected: true, + onCancelFailed: () => undefined, }; } @@ -250,6 +252,24 @@ describe("cancelComposerAgent", () => { expect(input.client.canceledIds).toEqual(["agent"]); }); + it("reports a rejected cancel so the composer can leave its canceling state", async () => { + const cancellationError = new Error("Provider rejected the interrupt"); + const failures: unknown[] = []; + const input = baseInput(); + input.client.cancelAgent = async () => { + throw cancellationError; + }; + + const result = cancelComposerAgent({ + ...input, + onCancelFailed: (error: unknown) => failures.push(error), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result).toBe(true); + expect(failures).toEqual([cancellationError]); + }); + it("does nothing when the agent is not running", () => { const input = baseInput(); const result = cancelComposerAgent({ ...input, isAgentRunning: false }); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 816736ac0..f2a90c8b7 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -142,12 +142,18 @@ export interface CancelComposerAgentInput { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; + onCancelFailed: (error: unknown) => void; } export function cancelComposerAgent(input: CancelComposerAgentInput): boolean { if (!input.isAgentRunning || input.isCancellingAgent) return false; if (!input.isConnected || !input.client) return false; - void input.client.cancelAgent(input.agentId); + try { + void Promise.resolve(input.client.cancelAgent(input.agentId)).catch(input.onCancelFailed); + } catch (error) { + input.onCancelFailed(error); + return false; + } return true; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 396b279ad..3cd4760f7 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -416,7 +416,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R ); } -function resolveVoiceStartErrorMessage(error: unknown): string | null { +function resolveErrorMessage(error: unknown): string | null { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return null; @@ -438,7 +438,7 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void { if (voice.isVoiceModeForAgent(serverId, agentId)) return; void voice.startVoice(serverId, agentId).catch((error) => { console.error("[Composer] Failed to start voice mode", error); - const message = resolveVoiceStartErrorMessage(error); + const message = resolveErrorMessage(error); if (message && message.trim().length > 0) { toastErrorRef.current(message); } @@ -1466,6 +1466,13 @@ export function Composer({ isAgentRunning, isCancellingAgent, isConnected, + onCancelFailed: (error) => { + setIsCancellingAgent(false); + const message = resolveErrorMessage(error); + if (message && message.trim().length > 0) { + toastErrorRef.current(message); + } + }, }); if (!didCancel) return; setIsCancellingAgent(true); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 73dd7b1a9..6d57619ea 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -51,7 +51,6 @@ import { } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; -import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; @@ -67,9 +66,15 @@ import { resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, } from "./labels"; -import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state"; +import { + computeCanStartDictation, + runAlternateSendAction, + runDefaultSendAction, + stopRealtimeVoice, +} from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; +const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { id: string; @@ -930,31 +935,6 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis await ctx.startDictation(); } -interface StopRealtimeVoiceContext { - voice: { stopVoice: () => Promise } | null | undefined; - isRealtimeVoiceForCurrentAgent: boolean; - isAgentRunning: boolean; - client: { cancelAgent: (agentId: string) => Promise } | null; - voiceAgentId: string | undefined; -} - -async function stopRealtimeVoiceImpl(ctx: StopRealtimeVoiceContext): Promise { - if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return; - - const tasks: Promise[] = []; - if (ctx.isAgentRunning && ctx.client && ctx.voiceAgentId) { - tasks.push(ctx.client.cancelAgent(ctx.voiceAgentId)); - } - tasks.push(ctx.voice.stopVoice()); - - const results = await Promise.allSettled(tasks); - results.forEach((result) => { - if (result.status === "rejected") { - console.error("[MessageInput] Failed to stop realtime voice", result.reason); - } - }); -} - interface VoicePressContext { isRealtimeVoiceForCurrentAgent: boolean; voice: { toggleMute: () => void } | null | undefined; @@ -1503,17 +1483,23 @@ export const MessageInput = forwardRef( discardFailedDictation(); }, [discardFailedDictation]); - const handleStopRealtimeVoice = useCallback( - () => - stopRealtimeVoiceImpl({ + const handleStopRealtimeVoice = useCallback(async () => { + try { + await stopRealtimeVoice({ voice, isRealtimeVoiceForCurrentAgent, isAgentRunning, client, voiceAgentId, - }), - [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId], - ); + }); + } catch (error) { + console.error("[MessageInput] Failed to stop realtime voice", error); + const message = extractErrorMessage(error); + if (message && message.trim().length > 0) { + toast.error(message); + } + } + }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]); const handleToggleRealtimeVoiceShortcut = useCallback(() => { toggleRealtimeVoiceImpl({ @@ -1616,10 +1602,6 @@ export const MessageInput = forwardRef( } }, [getWebTextArea]); - const inputScrollbar = useWebElementScrollbar(webTextareaRef, { - enabled: isWeb, - }); - usePasteImagesEffect({ getWebTextArea, isConnected, @@ -1826,6 +1808,7 @@ export const MessageInput = forwardRef( ( onSelectionChange={handleSelectionChange} autoFocus={isWeb && autoFocus} /> - {inputScrollbar} { expect(alternateAction.calls).toEqual(["send"]); }); }); + +describe("stopRealtimeVoice", () => { + it("keeps voice mode active when the running agent refuses cancellation", async () => { + const cancellationError = new Error("active run cancellation was not acknowledged"); + const cancelAgent = vi.fn().mockRejectedValue(cancellationError); + const stopVoice = vi.fn().mockResolvedValue(undefined); + + await expect( + stopRealtimeVoice({ + voice: { stopVoice }, + isRealtimeVoiceForCurrentAgent: true, + isAgentRunning: true, + client: { cancelAgent }, + voiceAgentId: "agent-1", + }), + ).rejects.toBe(cancellationError); + + expect(stopVoice).not.toHaveBeenCalled(); + }); + + it("stops voice mode after the running agent acknowledges cancellation", async () => { + const calls: string[] = []; + + await stopRealtimeVoice({ + voice: { + stopVoice: async () => { + calls.push("stop voice"); + }, + }, + isRealtimeVoiceForCurrentAgent: true, + isAgentRunning: true, + client: { + cancelAgent: async () => { + calls.push("cancel agent"); + }, + }, + voiceAgentId: "agent-1", + }); + + expect(calls).toEqual(["cancel agent", "stop voice"]); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 9eaa554fc..e2f714623 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -3,6 +3,14 @@ import type { MessagePayload } from "@/composer/types"; export type SendBehavior = "interrupt" | "queue"; +interface StopRealtimeVoiceContext { + voice: { stopVoice: () => Promise } | null | undefined; + isRealtimeVoiceForCurrentAgent: boolean; + isAgentRunning: boolean; + client: { cancelAgent: (agentId: string) => Promise } | null; + voiceAgentId: string | undefined; +} + interface SendActionContext { defaultSendBehavior: SendBehavior; isAgentRunning: boolean; @@ -41,3 +49,16 @@ export function runAlternateSendAction(ctx: SendActionContext): void { ctx.handleQueueMessage(); } } + +export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise { + if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return; + + if (ctx.isAgentRunning) { + if (!ctx.client || !ctx.voiceAgentId) { + throw new Error("Cannot stop the running voice agent while the host is unavailable"); + } + await ctx.client.cancelAgent(ctx.voiceAgentId); + } + + await ctx.voice.stopVoice(); +} diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index cf77d5dc0..676f1bb86 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -15,6 +15,13 @@ export const HEADER_TOP_PADDING_MOBILE = 8; export const MAX_CONTENT_WIDTH = 820; export const COMPACT_FORM_FACTOR_WIDTH = 500; +// Settings uses the canonical desktop list + detail layout. Its sidebar and +// detail target must fit together before it can share width with app navigation. +export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320; +export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400; +export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH = + SETTINGS_DESKTOP_SIDEBAR_WIDTH + SETTINGS_DESKTOP_DETAIL_MIN_WIDTH; + // Desktop app constants for macOS traffic light buttons // These buttons (close/minimize/maximize) overlay the top-left corner export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index e5fb01e84..7d2ab8074 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -476,6 +476,15 @@ function applyToolErrorToMessages( ); } +function notifyVoiceAbortFailure( + data: Extract["payload"], + notifyError: (message: string) => void, +): void { + if (data.type === "error" && data.metadata?.voiceAbortFailed === true) { + notifyError(data.content); + } +} + interface SessionProviderSharedProps { children: ReactNode; serverId: string; @@ -1570,6 +1579,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setMessages(serverId, applyToolError); } + notifyVoiceAbortFailure(data, toast.error); + let activityType: "system" | "info" | "success" | "error" = "info"; if (data.type === "error") activityType = "error"; @@ -1805,6 +1816,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider applyWorkspaceSetupProgress, applyTimelineResponse, updateSessionServerInfo, + toast, voiceRuntime, voiceAudioEngine, ]); diff --git a/packages/app/src/desktop/components/browser-data-section.tsx b/packages/app/src/desktop/components/browser-data-section.tsx new file mode 100644 index 000000000..3d61f922e --- /dev/null +++ b/packages/app/src/desktop/components/browser-data-section.tsx @@ -0,0 +1,80 @@ +import { useCallback, useRef, useState } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/contexts/toast-context"; +import { getDesktopHost } from "@/desktop/host"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { useBrowserStore } from "@/stores/browser-store"; +import { settingsStyles } from "@/styles/settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; + +export function BrowserDataSection() { + const { t } = useTranslation(); + const toast = useToast(); + const clearInFlightRef = useRef(false); + const [isClearing, setIsClearing] = useState(false); + + const handleClear = useCallback(async () => { + if (clearInFlightRef.current) { + return; + } + + clearInFlightRef.current = true; + setIsClearing(true); + try { + const confirmed = await confirmDialog({ + title: t("settings.general.browserData.confirmTitle"), + message: t("settings.general.browserData.confirmMessage"), + confirmLabel: t("settings.general.browserData.clear"), + cancelLabel: t("common.actions.cancel"), + destructive: true, + }); + if (!confirmed) { + return; + } + + const clearProfile = getDesktopHost()?.browser?.clearProfile; + if (!clearProfile) { + throw new Error("Electron browser profile bridge is unavailable"); + } + + await clearProfile(Object.keys(useBrowserStore.getState().browsersById)); + toast.show(t("settings.general.browserData.success"), { variant: "success" }); + } catch { + toast.error(t("settings.general.browserData.error")); + } finally { + clearInFlightRef.current = false; + setIsClearing(false); + } + }, [t, toast]); + const clearButtonLabel = isClearing + ? t("settings.general.browserData.clearing") + : t("settings.general.browserData.clear"); + + return ( + + + + + + {t("settings.general.browserData.siteData")} + + + {t("settings.general.browserData.description")} + + + + + + + ); +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index e53fdf175..fb4eb0329 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -98,6 +98,7 @@ export interface DesktopWindowControlsOverlayUpdate { export interface DesktopWindowBridge { label?: string; toggleMaximize?: () => Promise; + setFullscreen?: (fullscreen: boolean) => Promise; isFullscreen?: () => Promise; updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise; onResized?: ( @@ -127,16 +128,23 @@ export interface DesktopBrowserNewTabRequestEvent { url: string; } +export interface DesktopAttachedBrowserRegistration { + browserId: string; + workspaceId: string; + webContentsId: number; +} + export interface DesktopBrowserBridge { setShortcutPolicy?: (input: BrowserKeyboardPolicy) => Promise; - registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise; + readonly profilePartition?: string; + registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise; unregisterWorkspaceBrowser?: (browserId: string) => Promise; setWorkspaceActiveBrowser?: (input: { workspaceId: string; browserId: string | null; }) => Promise; openDevTools?: (browserId: string) => Promise; - clearPartition?: (browserId: string) => Promise; + clearProfile?: (legacyBrowserIds: string[]) => Promise; executeAutomationCommand?: ( request: BrowserAutomationExecuteRequest, ) => Promise; diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 46a14a96f..8fc3b701c 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -85,7 +85,6 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { GitHubIcon } from "@/components/icons/github-icon"; import { lineNumberGutterWidth } from "@/components/code-insets"; -import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { BranchSwitcher } from "@/components/branch-switcher"; import { useGitActions } from "@/git/use-actions"; @@ -1536,8 +1535,6 @@ interface DiffBodyContentProps { diffListRef: RefObject | null>; handleDiffListLayout: (event: LayoutChangeEvent) => void; handleDiffListScroll: (event: NativeSyntheticEvent) => void; - onContentSizeChange: (width: number, height: number) => void; - showDesktopWebScrollbar: boolean; checkingRepositoryLabel: string; notRepositoryLabel: string; } @@ -1559,8 +1556,6 @@ function DiffBodyContent({ diffListRef, handleDiffListLayout, handleDiffListScroll, - onContentSizeChange, - showDesktopWebScrollbar, checkingRepositoryLabel, notRepositoryLabel, }: DiffBodyContentProps) { @@ -1621,9 +1616,8 @@ function DiffBodyContent({ testID="git-diff-scroll" onLayout={handleDiffListLayout} onScroll={handleDiffListScroll} - onContentSizeChange={onContentSizeChange} scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts. // Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll. removeClippedSubviews={false} @@ -1737,7 +1731,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = useChangesPreferences(); @@ -1963,9 +1956,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane } void updateChangesPreferences({ viewMode: nextViewMode }); }, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]); - const scrollbar = useWebScrollViewScrollbar(diffListRef, { - enabled: showDesktopWebScrollbar, - }); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); @@ -2089,25 +2079,17 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane [getBodyHeightKey], ); - const handleDiffListScroll = useCallback( - (event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - scrollbar.onScroll(event); - }, - [scrollbar], - ); + const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, []); - const handleDiffListLayout = useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; - if (!Number.isFinite(height) || height <= 0) { - return; - } - diffListViewportHeightRef.current = height; - scrollbar.onLayout(event); - }, - [scrollbar], - ); + const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height; + if (!Number.isFinite(height) || height <= 0) { + return; + } + diffListViewportHeightRef.current = height; + }, []); // Offset of the first item matching `predicate`, walking the SAME flatItems // list getFlatItemLayout uses so folder rows are counted (single source of @@ -2379,8 +2361,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane diffListRef={diffListRef} handleDiffListLayout={handleDiffListLayout} handleDiffListScroll={handleDiffListScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - showDesktopWebScrollbar={showDesktopWebScrollbar} checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} notRepositoryLabel={t("workspace.git.diff.notRepository")} /> @@ -2479,10 +2459,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane {prErrorMessage ? {prErrorMessage} : null} - - {bodyContent} - {hasChanges ? scrollbar.overlay : null} - + {bodyContent} ); } diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 38b3f84da..a864afc76 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -341,14 +341,14 @@ describe("appearance settings", () => { expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); - it("loads an explicit tool call detail level", async () => { + it("maps an unrecognized tool call detail level to overview", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "concise" }), + [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("concise"); + expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); it("clamps the UI font size into range and rejects non-numeric values", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index dc982aef7..808665f3e 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -12,16 +12,12 @@ export type SendBehavior = "interrupt" | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; export type WorkspaceTitleSource = "title" | "branch"; -export type ToolCallDetailLevel = "overview" | "concise" | "detailed"; +export type ToolCallDetailLevel = "overview" | "detailed"; const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); -const VALID_TOOL_CALL_DETAIL_LEVELS = new Set([ - "overview", - "concise", - "detailed", -]); +const VALID_TOOL_CALL_DETAIL_LEVELS = new Set(["overview", "detailed"]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; @@ -172,11 +168,16 @@ export function normalizeAppSettings(value: unknown): AppSettings { } function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null { - if ( - typeof stored.toolCallDetailLevel === "string" && - VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel) - ) { - return stored.toolCallDetailLevel; + if (stored.toolCallDetailLevel !== undefined) { + if ( + typeof stored.toolCallDetailLevel === "string" && + VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel) + ) { + return stored.toolCallDetailLevel; + } + // COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values + // deliberately follow the unknown-value fallback. Remove after 2027-01-14. + return "overview"; } if (typeof stored.compactToolCalls === "boolean") { // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. diff --git a/packages/app/src/hooks/use-web-scrollbar-style.d.ts b/packages/app/src/hooks/use-web-scrollbar-style.d.ts deleted file mode 100644 index afafdb944..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-web-scrollbar-style.web"; diff --git a/packages/app/src/hooks/use-web-scrollbar-style.native.ts b/packages/app/src/hooks/use-web-scrollbar-style.native.ts deleted file mode 100644 index d2f336ed5..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ViewStyle } from "react-native"; - -export function useWebScrollbarStyle(): ViewStyle | undefined { - return undefined; -} diff --git a/packages/app/src/hooks/use-web-scrollbar-style.web.ts b/packages/app/src/hooks/use-web-scrollbar-style.web.ts deleted file mode 100644 index 38d22c910..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.web.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo } from "react"; -import type { ViewStyle } from "react-native"; -import { useUnistyles } from "react-native-unistyles"; - -// CSS scrollbar properties are supported by React Native Web at runtime -// but are not included in React Native's ViewStyle type definition. -interface WebScrollbarStyle extends ViewStyle { - scrollbarColor: string; - scrollbarWidth: string; -} - -export function useWebScrollbarStyle(): WebScrollbarStyle { - const { theme } = useUnistyles(); - return useMemo( - (): WebScrollbarStyle => ({ - scrollbarColor: `${theme.colors.scrollbarHandle} transparent`, - scrollbarWidth: "thin", - }), - [theme.colors.scrollbarHandle], - ); -} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 0a39025b1..db9cb8e70 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -792,16 +792,13 @@ export const ar: TranslationResources = { }, help: { trigger: "المساعدة والدعم", - troubleshoot: "استكشاف الأخطاء وإصلاحها", + sectionHelp: "المساعدة", diagnostics: "تشغيل التشخيص", - diagnosticsDescription: "جمع تفاصيل التطبيق والمضيفين المتصلين", shortcuts: "اختصارات لوحة المفاتيح", - shortcutsDescription: "عرض اختصارات لوحة المفاتيح المتاحة", reportIssue: "الإبلاغ عن مشكلة", discord: "Discord", - discordDescription: "الأفضل للمساعدة السريعة والنقاش", github: "إنشاء مشكلة على GitHub", - githubDescription: "الإبلاغ عن خطأ يمكن إعادة إنتاجه", + whatsNew: "ما الجديد", version: "Paseo {{version}}", }, sections: { @@ -1373,6 +1370,8 @@ export const ar: TranslationResources = { detachTooltip: "فصل الوكيل الفرعي", archiveAction: "أرشيف{{label}}", archiveTooltip: "أرشفة الوكيل الفرعي", + archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", + archiveFinishedTooltip: "أرشفة المكتملين", }, panels: { draft: { @@ -1396,8 +1395,6 @@ export const ar: TranslationResources = { output: "الإخراج", }, toolCallGroup: { - title: "الأدوات", - accessibilityLabel: "الأدوات، {{count}} استدعاءات", editedFiles: { one: "حرّر {{count}} ملفًا", other: "حرّر {{count}} ملفات", @@ -1423,7 +1420,6 @@ export const ar: TranslationResources = { other: "استدعى Paseo {{count}} مرات", }, and: "و", - failed: "فشل {{count}}", }, renameModal: { rename: "إعادة تسمية", @@ -1492,6 +1488,17 @@ export const ar: TranslationResources = { }, general: { title: "عام", + browserData: { + title: "بيانات المتصفح", + siteData: "ملفات تعريف الارتباط وبيانات المواقع", + description: "تتشارك علامات تبويب المتصفح تسجيلات الدخول وبيانات المواقع عبر Paseo.", + clear: "مسح بيانات المتصفح", + clearing: "جارٍ المسح...", + confirmTitle: "هل تريد مسح بيانات المتصفح؟", + confirmMessage: "سيتم تسجيل خروجك من المواقع وإعادة تحميل علامات تبويب المتصفح المفتوحة.", + success: "تم مسح بيانات المتصفح.", + error: "تعذر مسح بيانات المتصفح.", + }, defaultSend: { label: "إرسال افتراضي", descriptions: { @@ -1523,13 +1530,12 @@ export const ar: TranslationResources = { description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", }, toolCallDetail: { - label: "تفاصيل استدعاءات الأدوات", - description: "كيفية ظهور نشاط الأدوات في الخط الزمني للوكيل", - accessibilityLabel: "حدد مستوى تفاصيل الأدوات ({{value}})", + label: "عرض استدعاءات الأدوات", + description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني", + accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})", options: { - overview: "نظرة عامة", - concise: "موجز", - detailed: "مفصل", + overview: "ملخص", + detailed: "التفاصيل الكاملة", }, }, language: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 37eab1582..b1099e66f 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -799,16 +799,13 @@ export const en = { }, help: { trigger: "Help and support", - troubleshoot: "Troubleshoot", + sectionHelp: "Help", diagnostics: "Run diagnostics", - diagnosticsDescription: "Collect app and connected host details", shortcuts: "Keyboard shortcuts", - shortcutsDescription: "View available keyboard shortcuts", reportIssue: "Report an issue", discord: "Discord", - discordDescription: "Best for quick help and discussion", github: "Create GitHub issue", - githubDescription: "Report a reproducible bug", + whatsNew: "What's new", version: "Paseo {{version}}", }, sections: { @@ -1381,6 +1378,8 @@ export const en = { detachTooltip: "Detach subagent", archiveAction: "Archive {{label}}", archiveTooltip: "Archive subagent", + archiveFinishedAction: "Archive finished subagents", + archiveFinishedTooltip: "Archive finished", }, panels: { draft: { @@ -1404,8 +1403,6 @@ export const en = { output: "Output", }, toolCallGroup: { - title: "Tools", - accessibilityLabel: "Tools, {{count}} calls", editedFiles: { one: "edited {{count}} file", other: "edited {{count}} files", @@ -1431,7 +1428,6 @@ export const en = { other: "called Paseo {{count}} times", }, and: "and", - failed: "{{count}} failed", }, renameModal: { rename: "Rename", @@ -1500,6 +1496,17 @@ export const en = { }, general: { title: "General", + browserData: { + title: "Browser data", + siteData: "Cookies and site data", + description: "Browser tabs share sign-ins and site data across Paseo.", + clear: "Clear browser data", + clearing: "Clearing...", + confirmTitle: "Clear browser data?", + confirmMessage: "Sites will be signed out and open browser tabs will reload.", + success: "Browser data cleared.", + error: "Couldn't clear browser data.", + }, defaultSend: { label: "Default send", descriptions: { @@ -1530,13 +1537,12 @@ export const en = { description: "Show agent thinking and chain-of-thought blocks fully expanded by default", }, toolCallDetail: { - label: "Tool call detail", - description: "How tool activity appears in agent timelines", - accessibilityLabel: "Select tool call detail ({{value}})", + label: "Tool call display", + description: "How tool calls appear in the timeline", + accessibilityLabel: "Select tool call display ({{value}})", options: { - overview: "Overview", - concise: "Concise", - detailed: "Detailed", + overview: "Summary", + detailed: "Full detail", }, }, language: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index aba840de3..085435438 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -819,16 +819,13 @@ export const es: TranslationResources = { }, help: { trigger: "Ayuda y soporte", - troubleshoot: "Solucionar problemas", + sectionHelp: "Ayuda", diagnostics: "Ejecutar diagnóstico", - diagnosticsDescription: "Recopila datos de la app y los hosts conectados", shortcuts: "Atajos de teclado", - shortcutsDescription: "Ver los atajos de teclado disponibles", reportIssue: "Informar de un problema", discord: "Discord", - discordDescription: "La mejor opción para ayuda rápida y conversación", github: "Crear incidencia en GitHub", - githubDescription: "Informar de un error reproducible", + whatsNew: "Novedades", version: "Paseo {{version}}", }, sections: { @@ -1412,6 +1409,8 @@ export const es: TranslationResources = { detachTooltip: "Separar subagente", archiveAction: "Archivo{{label}}", archiveTooltip: "Subagente de archivo", + archiveFinishedAction: "Archivar subagentes finalizados", + archiveFinishedTooltip: "Archivar finalizados", }, panels: { draft: { @@ -1435,8 +1434,6 @@ export const es: TranslationResources = { output: "Producción", }, toolCallGroup: { - title: "Herramientas", - accessibilityLabel: "Herramientas, {{count}} llamadas", editedFiles: { one: "editó {{count}} archivo", other: "editó {{count}} archivos", @@ -1462,7 +1459,6 @@ export const es: TranslationResources = { other: "llamó a Paseo {{count}} veces", }, and: "y", - failed: "{{count}} con error", }, renameModal: { rename: "Rebautizar", @@ -1531,6 +1527,19 @@ export const es: TranslationResources = { }, general: { title: "General", + browserData: { + title: "Datos del navegador", + siteData: "Cookies y datos de sitios", + description: + "Las pestañas del navegador comparten inicios de sesión y datos de sitios en Paseo.", + clear: "Borrar datos del navegador", + clearing: "Borrando...", + confirmTitle: "¿Borrar los datos del navegador?", + confirmMessage: + "Se cerrarán las sesiones de los sitios y se recargarán las pestañas abiertas del navegador.", + success: "Datos del navegador borrados.", + error: "No se pudieron borrar los datos del navegador.", + }, defaultSend: { label: "Envío predeterminado", descriptions: { @@ -1564,13 +1573,12 @@ export const es: TranslationResources = { "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", }, toolCallDetail: { - label: "Detalle de llamadas a herramientas", - description: "Cómo aparece la actividad de herramientas en las cronologías del agente", - accessibilityLabel: "Seleccionar detalle de herramientas ({{value}})", + label: "Visualización de llamadas a herramientas", + description: "Cómo aparecen las llamadas a herramientas en la cronología", + accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})", options: { overview: "Resumen", - concise: "Conciso", - detailed: "Detallado", + detailed: "Detalle completo", }, }, language: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 82681b4a0..302d268c0 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -818,16 +818,13 @@ export const fr: TranslationResources = { }, help: { trigger: "Aide et assistance", - troubleshoot: "Dépannage", + sectionHelp: "Aide", diagnostics: "Lancer le diagnostic", - diagnosticsDescription: "Collecter les détails de l’app et des hôtes connectés", shortcuts: "Raccourcis clavier", - shortcutsDescription: "Afficher les raccourcis clavier disponibles", reportIssue: "Signaler un problème", discord: "Discord", - discordDescription: "Idéal pour obtenir une aide rapide et échanger", github: "Créer un ticket GitHub", - githubDescription: "Signaler un bug reproductible", + whatsNew: "Nouveautés", version: "Paseo {{version}}", }, sections: { @@ -1415,6 +1412,8 @@ export const fr: TranslationResources = { detachTooltip: "Detacher le sous-agent", archiveAction: "Archiver{{label}}", archiveTooltip: "Sous-agent d'archivage", + archiveFinishedAction: "Archiver les sous-agents terminés", + archiveFinishedTooltip: "Archiver les terminés", }, panels: { draft: { @@ -1438,8 +1437,6 @@ export const fr: TranslationResources = { output: "Sortir", }, toolCallGroup: { - title: "Outils", - accessibilityLabel: "Outils, {{count}} appels", editedFiles: { one: "a modifié {{count}} fichier", other: "a modifié {{count}} fichiers", @@ -1465,7 +1462,6 @@ export const fr: TranslationResources = { other: "a appelé Paseo {{count}} fois", }, and: "et", - failed: "{{count}} en échec", }, renameModal: { rename: "Rebaptiser", @@ -1534,6 +1530,18 @@ export const fr: TranslationResources = { }, general: { title: "Général", + browserData: { + title: "Données du navigateur", + siteData: "Cookies et données des sites", + description: + "Les onglets du navigateur partagent les connexions et les données des sites dans Paseo.", + clear: "Effacer les données du navigateur", + clearing: "Effacement...", + confirmTitle: "Effacer les données du navigateur ?", + confirmMessage: "Vous serez déconnecté des sites et les onglets ouverts seront rechargés.", + success: "Données du navigateur effacées.", + error: "Impossible d'effacer les données du navigateur.", + }, defaultSend: { label: "Envoi par défaut", descriptions: { @@ -1566,13 +1574,12 @@ export const fr: TranslationResources = { description: "Afficher le raisonnement de l'agent entièrement développé par défaut", }, toolCallDetail: { - label: "Détail des appels d’outils", - description: "Affichage de l’activité des outils dans la chronologie de l’agent", - accessibilityLabel: "Sélectionner le détail des outils ({{value}})", + label: "Affichage des appels d’outils", + description: "Comment les appels d’outils apparaissent dans la chronologie", + accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})", options: { - overview: "Vue d’ensemble", - concise: "Concis", - detailed: "Détaillé", + overview: "Résumé", + detailed: "Détails complets", }, }, language: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e43962423..a59d26e72 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -804,16 +804,13 @@ export const ja: TranslationResources = { }, help: { trigger: "ヘルプとサポート", - troubleshoot: "トラブルシューティング", + sectionHelp: "ヘルプ", diagnostics: "診断を実行", - diagnosticsDescription: "アプリと接続中のホストの詳細を収集", shortcuts: "キーボードショートカット", - shortcutsDescription: "利用可能なキーボードショートカットを表示", reportIssue: "問題を報告", discord: "Discord", - discordDescription: "すばやいサポートや相談に最適", github: "GitHub Issueを作成", - githubDescription: "再現可能なバグを報告", + whatsNew: "新着情報", version: "Paseo {{version}}", }, sections: { @@ -1390,6 +1387,8 @@ export const ja: TranslationResources = { detachTooltip: "サブエージェントを切り離す", archiveAction: "{{label}}をアーカイブ", archiveTooltip: "サブエージェントをアーカイブ", + archiveFinishedAction: "完了したサブエージェントをアーカイブ", + archiveFinishedTooltip: "完了した項目をアーカイブ", }, panels: { draft: { @@ -1413,8 +1412,6 @@ export const ja: TranslationResources = { output: "出力", }, toolCallGroup: { - title: "ツール", - accessibilityLabel: "ツール、{{count}}件の呼び出し", editedFiles: { one: "{{count}}個のファイルを編集", other: "{{count}}個のファイルを編集", @@ -1440,7 +1437,6 @@ export const ja: TranslationResources = { other: "Paseoを{{count}}回呼び出し", }, and: "および", - failed: "{{count}}件失敗", }, renameModal: { rename: "名前を変更", @@ -1509,6 +1505,17 @@ export const ja: TranslationResources = { }, general: { title: "一般", + browserData: { + title: "ブラウザーデータ", + siteData: "Cookie とサイトデータ", + description: "ブラウザータブ間でログイン情報とサイトデータが共有されます。", + clear: "ブラウザーデータを消去", + clearing: "消去中...", + confirmTitle: "ブラウザーデータを消去しますか?", + confirmMessage: "サイトからログアウトし、開いているブラウザータブを再読み込みします。", + success: "ブラウザーデータを消去しました。", + error: "ブラウザーデータを消去できませんでした。", + }, defaultSend: { label: "デフォルトの送信", descriptions: { @@ -1539,13 +1546,12 @@ export const ja: TranslationResources = { description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", }, toolCallDetail: { - label: "ツール呼び出しの詳細", - description: "エージェントのタイムラインでのツール活動の表示方法", - accessibilityLabel: "ツール詳細を選択({{value}})", + label: "ツール呼び出しの表示", + description: "タイムラインでのツール呼び出しの表示方法", + accessibilityLabel: "ツール呼び出しの表示を選択({{value}})", options: { - overview: "概要", - concise: "簡潔", - detailed: "詳細", + overview: "要約", + detailed: "すべての詳細", }, }, language: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 7369b1db8..b5db0d657 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -810,16 +810,13 @@ export const ptBR: TranslationResources = { }, help: { trigger: "Ajuda e suporte", - troubleshoot: "Resolver problemas", + sectionHelp: "Ajuda", diagnostics: "Executar diagnóstico", - diagnosticsDescription: "Coletar detalhes do app e dos hosts conectados", shortcuts: "Atalhos de teclado", - shortcutsDescription: "Ver os atalhos de teclado disponíveis", reportIssue: "Relatar um problema", discord: "Discord", - discordDescription: "Ideal para ajuda rápida e conversa", github: "Criar issue no GitHub", - githubDescription: "Relatar um bug reproduzível", + whatsNew: "Novidades", version: "Paseo {{version}}", }, sections: { @@ -1398,6 +1395,8 @@ export const ptBR: TranslationResources = { detachTooltip: "Desanexar subagente", archiveAction: "Arquivar {{label}}", archiveTooltip: "Arquivar subagente", + archiveFinishedAction: "Arquivar subagentes concluídos", + archiveFinishedTooltip: "Arquivar concluídos", }, panels: { draft: { @@ -1421,8 +1420,6 @@ export const ptBR: TranslationResources = { output: "Saída", }, toolCallGroup: { - title: "Ferramentas", - accessibilityLabel: "Ferramentas, {{count}} chamadas", editedFiles: { one: "editou {{count}} arquivo", other: "editou {{count}} arquivos", @@ -1448,7 +1445,6 @@ export const ptBR: TranslationResources = { other: "chamou o Paseo {{count}} vezes", }, and: "e", - failed: "{{count}} com falha", }, renameModal: { rename: "Renomear", @@ -1517,6 +1513,18 @@ export const ptBR: TranslationResources = { }, general: { title: "Geral", + browserData: { + title: "Dados do navegador", + siteData: "Cookies e dados de sites", + description: "As abas do navegador compartilham logins e dados de sites no Paseo.", + clear: "Limpar dados do navegador", + clearing: "Limpando...", + confirmTitle: "Limpar dados do navegador?", + confirmMessage: + "Você será desconectado dos sites e as abas abertas do navegador serão recarregadas.", + success: "Dados do navegador limpos.", + error: "Não foi possível limpar os dados do navegador.", + }, defaultSend: { label: "Envio padrão", descriptions: { @@ -1549,13 +1557,12 @@ export const ptBR: TranslationResources = { "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", }, toolCallDetail: { - label: "Detalhe das chamadas de ferramentas", - description: "Como a atividade das ferramentas aparece na linha do tempo do agente", - accessibilityLabel: "Selecionar detalhe das ferramentas ({{value}})", + label: "Exibição de chamadas de ferramentas", + description: "Como as chamadas de ferramentas aparecem na linha do tempo", + accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})", options: { - overview: "Visão geral", - concise: "Conciso", - detailed: "Detalhado", + overview: "Resumo", + detailed: "Detalhes completos", }, }, language: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 208d4dbbc..afa6d4c0a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -811,16 +811,13 @@ export const ru: TranslationResources = { }, help: { trigger: "Помощь и поддержка", - troubleshoot: "Устранение неполадок", + sectionHelp: "Помощь", diagnostics: "Запустить диагностику", - diagnosticsDescription: "Собрать данные приложения и подключённых хостов", shortcuts: "Сочетания клавиш", - shortcutsDescription: "Показать доступные сочетания клавиш", reportIssue: "Сообщить о проблеме", discord: "Discord", - discordDescription: "Для быстрой помощи и обсуждения", github: "Создать issue в GitHub", - githubDescription: "Сообщить о воспроизводимой ошибке", + whatsNew: "Что нового", version: "Paseo {{version}}", }, sections: { @@ -1404,6 +1401,8 @@ export const ru: TranslationResources = { detachTooltip: "Отсоединить субагент", archiveAction: "Архив{{label}}", archiveTooltip: "Архивный субагент", + archiveFinishedAction: "Архивировать завершенные субагенты", + archiveFinishedTooltip: "Архивировать завершенные", }, panels: { draft: { @@ -1427,8 +1426,6 @@ export const ru: TranslationResources = { output: "Выход", }, toolCallGroup: { - title: "Инструменты", - accessibilityLabel: "Инструменты, вызовов: {{count}}", editedFiles: { one: "изменён {{count}} файл", other: "изменено {{count}} файлов", @@ -1454,7 +1451,6 @@ export const ru: TranslationResources = { other: "Paseo вызван {{count}} раз", }, and: "и", - failed: "С ошибкой: {{count}}", }, renameModal: { rename: "Переименовать", @@ -1523,6 +1519,18 @@ export const ru: TranslationResources = { }, general: { title: "Общий", + browserData: { + title: "Данные браузера", + siteData: "Файлы cookie и данные сайтов", + description: "Вкладки браузера используют общие данные входа и данные сайтов в Paseo.", + clear: "Очистить данные браузера", + clearing: "Очистка...", + confirmTitle: "Очистить данные браузера?", + confirmMessage: + "На сайтах будет выполнен выход, а открытые вкладки браузера перезагрузятся.", + success: "Данные браузера очищены.", + error: "Не удалось очистить данные браузера.", + }, defaultSend: { label: "Отправка по умолчанию", descriptions: { @@ -1554,13 +1562,12 @@ export const ru: TranslationResources = { "По умолчанию показывать блоки размышлений и логики агента полностью развернутыми", }, toolCallDetail: { - label: "Детализация вызовов инструментов", - description: "Отображение активности инструментов в хронологии агента", - accessibilityLabel: "Выбрать детализацию инструментов ({{value}})", + label: "Отображение вызовов инструментов", + description: "Как вызовы инструментов отображаются на временной шкале", + accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})", options: { - overview: "Обзор", - concise: "Кратко", - detailed: "Подробно", + overview: "Сводка", + detailed: "Полная детализация", }, }, language: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f97c00a35..e947d3622 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -787,16 +787,13 @@ export const zhCN: TranslationResources = { }, help: { trigger: "帮助与支持", - troubleshoot: "问题排查", + sectionHelp: "帮助", diagnostics: "运行诊断", - diagnosticsDescription: "收集应用和已连接 Host 的详细信息", shortcuts: "键盘快捷键", - shortcutsDescription: "查看可用的键盘快捷键", reportIssue: "报告问题", discord: "Discord", - discordDescription: "适合快速求助和讨论", github: "创建 GitHub Issue", - githubDescription: "报告可复现的 bug", + whatsNew: "新功能", version: "Paseo {{version}}", }, sections: { @@ -1357,6 +1354,8 @@ export const zhCN: TranslationResources = { detachTooltip: "分离 subagent", archiveAction: "归档 {{label}}", archiveTooltip: "归档 subagent", + archiveFinishedAction: "归档已完成的 subagent", + archiveFinishedTooltip: "归档已完成项", }, panels: { draft: { @@ -1380,8 +1379,6 @@ export const zhCN: TranslationResources = { output: "输出", }, toolCallGroup: { - title: "工具", - accessibilityLabel: "工具,{{count}} 次调用", editedFiles: { one: "编辑了 {{count}} 个文件", other: "编辑了 {{count}} 个文件", @@ -1407,7 +1404,6 @@ export const zhCN: TranslationResources = { other: "调用了 Paseo {{count}} 次", }, and: "并", - failed: "{{count}} 次失败", }, renameModal: { rename: "重命名", @@ -1476,6 +1472,17 @@ export const zhCN: TranslationResources = { }, general: { title: "通用", + browserData: { + title: "浏览器数据", + siteData: "Cookie 和网站数据", + description: "浏览器标签页在 Paseo 中共享登录状态和网站数据。", + clear: "清除浏览器数据", + clearing: "正在清除...", + confirmTitle: "清除浏览器数据?", + confirmMessage: "网站帐号将退出登录,打开的浏览器标签页将重新加载。", + success: "浏览器数据已清除。", + error: "无法清除浏览器数据。", + }, defaultSend: { label: "默认发送", descriptions: { @@ -1506,13 +1513,12 @@ export const zhCN: TranslationResources = { description: "默认情况下完全展开 AI 的思考和推理过程", }, toolCallDetail: { - label: "工具调用详情", - description: "工具活动在智能体时间线中的显示方式", - accessibilityLabel: "选择工具调用详情({{value}})", + label: "工具调用显示", + description: "工具调用在时间线中的显示方式", + accessibilityLabel: "选择工具调用显示方式({{value}})", options: { - overview: "概览", - concise: "简洁", - detailed: "详细", + overview: "摘要", + detailed: "完整详情", }, }, language: { diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index 2a6cdf950..ca0987756 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, View } from "react-native"; import { GestureDetector, type GestureType } from "react-native-gesture-handler"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { isWeb } from "@/constants/platform"; +import { WindowChromeRootRegion } from "@/utils/desktop-window"; import { usePanelStore, type MobilePanelView } from "@/stores/panel-store"; import { getMobilePanelFrame } from "./model"; import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider"; @@ -82,7 +83,7 @@ export function MobilePanelOverlay({ - {children} + {children} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 73bf635f9..ba7b899a7 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -68,7 +68,12 @@ import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import type { Theme } from "@/styles/theme"; -import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents"; +import { + useHideFinishedProviderSubagents, + useArchiveSubagent, + useDetachSubagent, + useSubagentsForParent, +} from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; @@ -1390,6 +1395,10 @@ function ActiveAgentComposer({ ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); + const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({ + serverId, + parentAgentId: agentId, + }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd, @@ -1490,6 +1499,7 @@ function ActiveAgentComposer({ onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} onArchiveSubagent={handleArchiveSubagent} + onArchiveFinished={handleHideFinishedProviderSubagents} onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} /> !item.desktopOnly || isDesktopApp); const insets = useSafeAreaInsets(); - const padding = useWindowControlsPadding("sidebar"); const isDesktop = layout === "desktop"; const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], @@ -1006,7 +1005,6 @@ function SettingsSidebar({ const selectedSectionId = view.kind === "section" ? view.section : null; const selectedHostSection = view.kind === "host" ? view.section : null; const isProjectsSelected = view.kind === "projects" || view.kind === "project"; - const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const sidebarBody = ( <> @@ -1088,7 +1086,7 @@ function SettingsSidebar({ - {padding.top > 0 ? : null} + ({ paddingBottom: insets.bottom }), [insets.bottom]); - const webScrollbarStyle = useWebScrollbarStyle(); - const scrollViewStyle = useMemo( - () => [styles.scrollView, webScrollbarStyle], - [webScrollbarStyle], - ); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); @@ -1393,14 +1386,17 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti switch (view.section) { case "general": return ( - + <> + + {isDesktopApp ? : null} + ); case "appearance": return ; @@ -1440,6 +1436,16 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti ); } + const desktopDetailHeaderLeft = detailHeader ? ( + <> + + + + {detailHeader.title} + {detailHeader.titleAccessory} + + ) : null; + const addHostModals = ( <> - + - + {content} {addHostModals} @@ -1514,43 +1520,31 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti return ( - - - - - - - - {detailHeader.title} - - {detailHeader.titleAccessory} - - ) : null - } - leftStyle={desktopStyles.detailLeft} + + - - {content} - - + + + + + + {content} + + + {addHostModals} @@ -1660,7 +1654,7 @@ const desktopStyles = StyleSheet.create((theme) => ({ const sidebarStyles = StyleSheet.create((theme) => ({ desktopContainer: { - width: 320, + width: SETTINGS_DESKTOP_SIDEBAR_WIDTH, borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 9a0162406..4ae7b2886 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -214,9 +214,8 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps const TOOL_CALL_DETAIL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ - "overview", - "concise", "detailed", + "overview", ]; function getToolCallDetailLevelLabel( diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index fdb5d6ce9..abf73797f 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -20,7 +20,6 @@ import { Button } from "@/components/ui/button"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { isNative, isWeb } from "@/constants/platform"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; interface StartupSplashScreenProps { @@ -300,15 +299,6 @@ const styles = StyleSheet.create((theme) => ({ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); - const webScrollbarStyle = useWebScrollbarStyle(); - const errorScrollViewStyle = useMemo( - () => [styles.errorScrollView, webScrollbarStyle], - [webScrollbarStyle], - ); - const logsScrollStyle = useMemo( - () => [styles.logsScroll, webScrollbarStyle], - [webScrollbarStyle], - ); const [daemonLogs, setDaemonLogs] = useState(null); const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); @@ -404,7 +394,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps @@ -424,7 +414,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index b35b3a1b4..054ae4f2c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -6,6 +6,7 @@ import { useRef, useState, type ReactElement, + type ComponentProps, type ReactNode, } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -61,6 +62,7 @@ import { import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { SplitContainer } from "@/components/split-container"; import { RetainedPanel } from "@/components/retained-panel"; +import { WindowChromeRegion } from "@/utils/desktop-window"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; @@ -1566,6 +1568,46 @@ function shouldShowWorkspaceExplorerSidebar(input: { return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); } +interface WorkspaceChromeRowProps extends Omit< + ComponentProps, + "workspaceRoot" +> { + children: ReactNode; + explorerOpen: boolean; + portalHostName: string; + showExplorerSidebar: boolean; + workspaceRoot: string | null; +} + +function WorkspaceChromeRow({ + children, + explorerOpen, + portalHostName, + showExplorerSidebar, + workspaceRoot, + ...explorerProps +}: WorkspaceChromeRowProps) { + const explorerRendered = showExplorerSidebar && explorerOpen && workspaceRoot !== null; + + return ( + + + + {children} + + + + + + {showExplorerSidebar && workspaceRoot ? ( + + + + ) : null} + + ); +} + function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { if (!serverId || !workspaceId) { return null; @@ -1928,7 +1970,7 @@ function WorkspaceScreenContent({ const { browserId } = input.target; useBrowserStore.getState().removeBrowser(browserId); removeResidentBrowserWebview(browserId); - void getDesktopHost()?.browser?.clearPartition?.(browserId); + void getDesktopHost()?.browser?.unregisterWorkspaceBrowser?.(browserId); } closeWorkspaceTab(persistenceKey, normalizedTabId); }, @@ -3624,23 +3666,18 @@ function WorkspaceScreenContent({ workspaceId={normalizedWorkspaceId} isRouteFocused={isRouteFocused} /> - - - {workspaceCenterColumn} - - - - - {showExplorerSidebar && workspaceDirectory ? ( - - ) : null} - + + {workspaceCenterColumn} + (); let gcScheduled = false; +const draftPersistStorage = createDraftPersistStorage( + createJSONStorage(() => AsyncStorage), +); + +export function flushDraftPersistStorage(): Promise { + return draftPersistStorage?.flush() ?? Promise.resolve(); +} function createDraftRecord(input: { draft: DraftInput; @@ -378,7 +386,7 @@ export const useDraftStore = create()( { name: "paseo-drafts", version: DRAFT_STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), + storage: draftPersistStorage, migrate: (persistedState) => { return migratePersistedState(persistedState, { migrateLegacyImages, diff --git a/packages/app/src/stores/draft-store/persistence.test.ts b/packages/app/src/stores/draft-store/persistence.test.ts new file mode 100644 index 000000000..bae660abc --- /dev/null +++ b/packages/app/src/stores/draft-store/persistence.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { PersistStorage, StorageValue } from "zustand/middleware"; +import { + createDraftPersistStorage, + DRAFT_PERSIST_INTERVAL_MS, + type PersistenceScheduler, +} from "./persistence"; + +interface DraftState { + text: string; +} + +function createDraftPersistence() { + let nowMs = 0; + let saved: StorageValue | null = null; + let scheduled: { callback: () => void; dueAt: number } | null = null; + const storage: PersistStorage = { + getItem: () => saved, + setItem: (_name, value) => { + saved = value; + }, + removeItem: () => { + saved = null; + }, + }; + const scheduler: PersistenceScheduler = { + now: () => nowMs, + schedule: (callback, delayMs) => (scheduled = { callback, dueAt: nowMs + delayMs }), + cancel: () => { + scheduled = null; + }, + }; + const drafts = createDraftPersistStorage(storage, scheduler); + + return { + save(text: string) { + drafts.setItem("drafts", { state: { text } }); + }, + remove() { + drafts.removeItem("drafts"); + }, + flush() { + return drafts.flush(); + }, + advance(ms: number) { + nowMs += ms; + if (scheduled && scheduled.dueAt <= nowMs) { + const { callback } = scheduled; + scheduled = null; + callback(); + } + }, + text() { + return saved?.state.text ?? null; + }, + }; +} + +describe("draft persistence", () => { + it("checkpoints the first change and the latest change in each interval", () => { + const drafts = createDraftPersistence(); + + drafts.save("a"); + drafts.save("ab"); + drafts.save("abc"); + expect(drafts.text()).toBe("a"); + + drafts.advance(DRAFT_PERSIST_INTERVAL_MS - 1); + expect(drafts.text()).toBe("a"); + + drafts.advance(1); + expect(drafts.text()).toBe("abc"); + }); + + it("does not restore a pending draft after storage is cleared", () => { + const drafts = createDraftPersistence(); + + drafts.save("first checkpoint"); + drafts.save("pending checkpoint"); + drafts.remove(); + drafts.advance(DRAFT_PERSIST_INTERVAL_MS); + + expect(drafts.text()).toBeNull(); + }); + + it("continues checkpointing the latest change across consecutive intervals", () => { + const drafts = createDraftPersistence(); + + drafts.save("first"); + drafts.save("first interval"); + drafts.advance(DRAFT_PERSIST_INTERVAL_MS); + expect(drafts.text()).toBe("first interval"); + + drafts.save("second"); + drafts.save("second interval"); + drafts.advance(DRAFT_PERSIST_INTERVAL_MS); + expect(drafts.text()).toBe("second interval"); + }); + + it("flushes the latest pending change before the interval ends", async () => { + const drafts = createDraftPersistence(); + + drafts.save("first checkpoint"); + drafts.save("pending checkpoint"); + await drafts.flush(); + + expect(drafts.text()).toBe("pending checkpoint"); + }); +}); diff --git a/packages/app/src/stores/draft-store/persistence.ts b/packages/app/src/stores/draft-store/persistence.ts new file mode 100644 index 000000000..ab7c374c5 --- /dev/null +++ b/packages/app/src/stores/draft-store/persistence.ts @@ -0,0 +1,82 @@ +import type { PersistStorage } from "zustand/middleware"; + +export const DRAFT_PERSIST_INTERVAL_MS = 200; + +export interface PersistenceScheduler { + now: () => number; + schedule: (callback: () => void, delayMs: number) => unknown; + cancel: (handle: unknown) => void; +} + +export interface DraftPersistStorage extends PersistStorage { + flush: () => Promise; +} + +const systemScheduler: PersistenceScheduler = { + now: Date.now, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + cancel: (handle) => clearTimeout(handle as ReturnType), +}; + +export function createDraftPersistStorage( + storage: PersistStorage, + scheduler?: PersistenceScheduler, +): DraftPersistStorage; +export function createDraftPersistStorage( + storage: PersistStorage | undefined, + scheduler?: PersistenceScheduler, +): DraftPersistStorage | undefined; +export function createDraftPersistStorage( + storage: PersistStorage | undefined, + scheduler: PersistenceScheduler = systemScheduler, +): DraftPersistStorage | undefined { + if (!storage) { + return undefined; + } + + let pending: { name: string; value: Parameters[1] } | null = null; + let timer: unknown = null; + let lastWriteAt = -Infinity; + + const cancelTimer = () => { + if (timer !== null) { + scheduler.cancel(timer); + timer = null; + } + }; + const flush = async (): Promise => { + cancelTimer(); + const write = pending; + pending = null; + if (!write) { + return; + } + lastWriteAt = scheduler.now(); + try { + await storage.setItem(write.name, write.value); + } catch (error) { + console.warn("[DraftStore] Failed to persist draft checkpoint", error); + } + }; + + return { + getItem: (name) => storage.getItem(name), + setItem: (name, value) => { + pending = { name, value }; + const delay = DRAFT_PERSIST_INTERVAL_MS - (scheduler.now() - lastWriteAt); + if (delay <= 0) { + return flush(); + } + timer ??= scheduler.schedule(() => { + void flush(); + }, delay); + }, + removeItem: (name) => { + cancelTimer(); + pending = null; + lastWriteAt = scheduler.now(); + return storage.removeItem(name); + }, + flush, + }; +} diff --git a/packages/app/src/styles/install-web-scrollbar-styles.ts b/packages/app/src/styles/install-web-scrollbar-styles.ts new file mode 100644 index 000000000..fe7cf9289 --- /dev/null +++ b/packages/app/src/styles/install-web-scrollbar-styles.ts @@ -0,0 +1,3 @@ +export function installWebScrollbarStyles(): () => void { + return () => {}; +} diff --git a/packages/app/src/styles/install-web-scrollbar-styles.web.ts b/packages/app/src/styles/install-web-scrollbar-styles.web.ts new file mode 100644 index 000000000..db11698cd --- /dev/null +++ b/packages/app/src/styles/install-web-scrollbar-styles.web.ts @@ -0,0 +1,47 @@ +import { + WEB_SCROLLBAR_SIZE_PX, + webScrollbarColor, + webScrollbarThumbColor, + WEB_SCROLLBAR_WIDTH, +} from "@/styles/web-scrollbar"; + +const STYLE_ID = "paseo-web-scrollbar-styles"; + +export function installWebScrollbarStyles(): () => void { + const existingStyle = document.getElementById(STYLE_ID); + if (existingStyle) return () => {}; + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +* { + scrollbar-color: ${webScrollbarColor("var(--colors-scrollbar-handle)")}; + scrollbar-width: ${WEB_SCROLLBAR_WIDTH}; +} + +[data-composer-input] { + scrollbar-gutter: stable; +} + +*::-webkit-scrollbar { + width: ${WEB_SCROLLBAR_SIZE_PX}px; + height: ${WEB_SCROLLBAR_SIZE_PX}px; + background: transparent; +} + +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-corner { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: ${webScrollbarThumbColor("var(--colors-scrollbar-handle)")}; + background-clip: content-box; +} +`; + document.head.append(style); + + return () => style.remove(); +} diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index bab8cb8ea..284b05c74 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -20,7 +20,7 @@ StyleSheet.configure({ breakpoints: { xs: 0, sm: 576, - md: 768, + md: 720, lg: 992, xl: 1200, }, diff --git a/packages/app/src/styles/web-scrollbar.ts b/packages/app/src/styles/web-scrollbar.ts new file mode 100644 index 000000000..1fd4fbd80 --- /dev/null +++ b/packages/app/src/styles/web-scrollbar.ts @@ -0,0 +1,10 @@ +export const WEB_SCROLLBAR_WIDTH = "thin"; +export const WEB_SCROLLBAR_SIZE_PX = 8; + +export function webScrollbarThumbColor(handleColor: string): string { + return `color-mix(in srgb, ${handleColor} 62%, transparent)`; +} + +export function webScrollbarColor(handleColor: string): string { + return `${webScrollbarThumbColor(handleColor)} transparent`; +} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index c71969b95..959e54b69 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -2,5 +2,9 @@ export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent"; +export { + useHideFinishedProviderSubagents, + type UseHideFinishedProviderSubagentsInput, +} from "./use-hide-finished-provider-subagents"; export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; export { isWorkspaceRootAgent } from "./workspace-root-policy"; diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 03c6ade16..d14edbd9b 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -6,7 +6,11 @@ const PARENT_ID = "parent-1"; const SUBAGENT_ID = "child-1"; afterEach(() => { - useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); + useProviderSubagentStore.setState({ + descriptors: new Map(), + timelines: new Map(), + hiddenFromTrack: new Set(), + }); }); describe("provider subagent client store", () => { @@ -129,6 +133,123 @@ describe("provider subagent client store", () => { ).toBe(false); }); + test("hides finished children locally without removing their timelines", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Finished child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Finished output." }, + }); + + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + + const state = useProviderSubagentStore.getState(); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(state.descriptors.get(key)?.title).toBe("Finished child"); + expect(state.hiddenFromTrack.has(key)).toBe(true); + expect(state.timelines.get(key)?.tail).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Finished output." }), + ]); + }); + + test("reveals a hidden child when the provider reports it running again", () => { + const store = useProviderSubagentStore.getState(); + const completed = { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex" as const, + title: "Finished child", + description: null, + status: "completed" as const, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }; + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.replaceList(SERVER_ID, PARENT_ID, [completed]); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true); + + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { ...completed, status: "running", updatedAt: "2026-07-12T10:01:00.000Z" }, + }); + + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(false); + }); + + test("keeps hidden state when a child temporarily disappears from the provider list", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Finished child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + + store.replaceList(SERVER_ID, PARENT_ID, []); + + const state = useProviderSubagentStore.getState(); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(state.descriptors.has(key)).toBe(false); + expect(state.hiddenFromTrack.has(key)).toBe(true); + }); + + test("keeps a finished child hidden across remove and history replay", () => { + const store = useProviderSubagentStore.getState(); + const completed = { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex" as const, + title: "Finished child", + description: null, + status: "completed" as const, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }; + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.applyUpdate(SERVER_ID, { + kind: "remove", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + }); + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true); + }); test("applies terminal list status to a timeline received before its descriptor", () => { const store = useProviderSubagentStore.getState(); store.applyUpdate(SERVER_ID, { diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index f0d76ee70..6bd8b90f2 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -32,6 +32,8 @@ export interface ProviderSubagentTimelineState { interface ProviderSubagentState { descriptors: Map; timelines: Map; + hiddenFromTrack: Set; + hideFinishedForParent(serverId: string, parentAgentId: string): void; replaceList( serverId: string, parentAgentId: string, @@ -193,14 +195,32 @@ function buildTimelineResponseRows( export const useProviderSubagentStore = create((set) => ({ descriptors: new Map(), timelines: new Map(), + hiddenFromTrack: new Set(), + hideFinishedForParent(serverId, parentAgentId) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + const hiddenFromTrack = new Set(state.hiddenFromTrack); + for (const [key, subagent] of state.descriptors) { + if (key.startsWith(prefix) && subagent.status !== "running") { + hiddenFromTrack.add(key); + } + } + return { hiddenFromTrack }; + }); + }, replaceList(serverId, parentAgentId, subagents) { set((state) => { const prefix = parentPrefix(serverId, parentAgentId); const descriptors = new Map( [...state.descriptors].filter(([key]) => !key.startsWith(prefix)), ); + const hiddenFromTrack = new Set(state.hiddenFromTrack); for (const subagent of subagents) { - descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent); + const key = providerSubagentKey(serverId, parentAgentId, subagent.id); + descriptors.set(key, subagent); + if (subagent.status === "running") { + hiddenFromTrack.delete(key); + } } const retainedKeys = new Set(descriptors.keys()); const timelines = new Map( @@ -217,7 +237,7 @@ export const useProviderSubagentStore = create((set) => ( ); } } - return { descriptors, timelines }; + return { descriptors, timelines, hiddenFromTrack }; }); }, applyUpdate(serverId, payload) { @@ -229,8 +249,12 @@ export const useProviderSubagentStore = create((set) => ( payload.subagent.id, ); const descriptors = new Map(state.descriptors); + const hiddenFromTrack = new Set(state.hiddenFromTrack); const previous = descriptors.get(key); descriptors.set(key, payload.subagent); + if (payload.subagent.status === "running") { + hiddenFromTrack.delete(key); + } let timelines = state.timelines; const current = state.timelines.get(key); if (current && previous?.status !== payload.subagent.status) { @@ -240,13 +264,13 @@ export const useProviderSubagentStore = create((set) => ( buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), ); } - return { descriptors, timelines }; + return { descriptors, timelines, hiddenFromTrack }; } if (payload.kind === "remove") { const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); const descriptors = new Map(state.descriptors); - const timelines = new Map(state.timelines); descriptors.delete(key); + const timelines = new Map(state.timelines); timelines.delete(key); return { descriptors, timelines }; } diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 4a8599467..7545ea27c 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -59,7 +59,11 @@ function setAgents(agents: Agent[]): void { afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); - useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); + useProviderSubagentStore.setState({ + descriptors: new Map(), + timelines: new Map(), + hiddenFromTrack: new Set(), + }); }); describe("selectSubagentsForParent", () => { @@ -90,6 +94,34 @@ describe("selectSubagentsForParent", () => { ).toEqual(["provider-child"]); }); + it("hides locally dismissed provider children while retaining their descriptor", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: "provider-child", + parentAgentId: "parent-a", + provider: "codex", + title: "Provider child", + description: null, + status: "completed", + createdAt: "2026-03-08T10:01:00.000Z", + updatedAt: "2026-03-08T10:02:00.000Z", + toolCallId: "call-1", + }, + }); + store.hideFinishedForParent(SERVER_ID, "parent-a"); + + expect( + selectProviderSubagentsForParent( + useProviderSubagentStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent-a" }, + true, + ), + ).toEqual([]); + expect(useProviderSubagentStore.getState().descriptors.size).toBe(1); + }); + it("returns only non-archived children for the requested parent", () => { setAgents([ makeAgent({ id: "parent-a" }), diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index a235cc445..d96c1628a 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -91,7 +91,7 @@ export function selectProviderSubagentsForParent( const rows: ProviderSubagentRow[] = []; const prefix = `${params.serverId}\0${params.parentAgentId}\0`; for (const [key, subagent] of state.descriptors) { - if (!key.startsWith(prefix)) continue; + if (!key.startsWith(prefix) || state.hiddenFromTrack.has(key)) continue; rows.push({ kind: "provider", id: subagent.id, diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 2802c1707..5f74fd6bd 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { PaseoSubagentRow, SubagentRow } from "./select"; import { buildSubagentRowPresentationData, + countFinishedSubagents, formatHeaderLabel, resolveRowLabel, } from "./track-presentation"; @@ -72,6 +73,41 @@ describe("formatHeaderLabel", () => { }); }); +describe("countFinishedSubagents", () => { + it("counts only terminal provider-owned children", () => { + const providerRows: SubagentRow[] = [ + { + kind: "provider", + id: "native-running", + parentAgentId: "parent", + provider: "claude", + title: "running", + status: "running", + requiresAttention: false, + createdAt: new Date("2026-04-20T00:00:00.000Z"), + }, + { + kind: "provider", + id: "native-failed", + parentAgentId: "parent", + provider: "claude", + title: "failed", + status: "failed", + requiresAttention: true, + createdAt: new Date("2026-04-20T00:00:01.000Z"), + }, + ]; + + expect( + countFinishedSubagents([ + row({ id: "managed-running", status: "running" }), + row({ id: "managed-idle", status: "idle" }), + ...providerRows, + ]), + ).toBe(1); + }); +}); + describe("resolveRowLabel", () => { it("returns null when title is not a string", () => { expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d7255cc77..c4baa42f8 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -48,6 +48,10 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string { return parts.join(" · "); } +export function countFinishedSubagents(rows: readonly SubagentRow[]): number { + return rows.filter((row) => row.kind === "provider" && row.status !== "running").length; +} + export function resolveRowLabel(title: SubagentRow["title"]): string | null { if (typeof title !== "string") { return null; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index c0ff19ec3..9dd38c19b 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -13,7 +13,11 @@ import { } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; import type { SubagentRow } from "./select"; -import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation"; +import { + buildSubagentRowPresentationData, + countFinishedSubagents, + formatHeaderLabel, +} from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); @@ -30,6 +34,7 @@ export interface SubagentsTrackProps { onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; + onArchiveFinished?: () => void; onDetachSubagent?: (id: string) => void; } @@ -47,8 +52,10 @@ export function SubagentsTrack({ onOpenSubagent, onOpenProviderSubagent, onArchiveSubagent, + onArchiveFinished, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const toggleExpanded = useCallback(() => { @@ -62,10 +69,13 @@ export function SubagentsTrack({ const headerStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.header, - expanded ? styles.headerDivider : styles.headerCollapsed, + styles.headerToggle, (hovered || pressed) && styles.headerActive, ], + [], + ); + const headerContainerStyle = useMemo( + () => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed], [expanded], ); @@ -74,27 +84,42 @@ export function SubagentsTrack({ } const headerLabel = formatHeaderLabel(rows); + const finishedCount = countFinishedSubagents(rows); return ( - - {expanded ? ( - - ) : ( - - )} - - {headerLabel} - - + + + {expanded ? ( + + ) : ( + + )} + + {headerLabel} + + + {finishedCount > 0 && onArchiveFinished ? ( + + + + ) : null} + {expanded ? ( ({ header: { flexDirection: "row", alignItems: "center", + }, + headerToggle: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[1], paddingVertical: theme.spacing[2], }, + headerAction: { + paddingRight: theme.spacing[2], + }, headerCollapsed: { - paddingBottom: theme.spacing[6], + paddingBottom: theme.spacing[4], }, headerActive: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/subagents/use-hide-finished-provider-subagents.ts b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts new file mode 100644 index 000000000..7058fc8f1 --- /dev/null +++ b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; +import { useProviderSubagentStore } from "./provider-store"; + +export interface UseHideFinishedProviderSubagentsInput { + serverId: string; + parentAgentId: string; +} + +export function useHideFinishedProviderSubagents({ + serverId, + parentAgentId, +}: UseHideFinishedProviderSubagentsInput): () => void { + return useCallback(() => { + useProviderSubagentStore.getState().hideFinishedForParent(serverId, parentAgentId); + }, [parentAgentId, serverId]); +} diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index a3e6acf5e..e0026371c 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -187,6 +187,25 @@ const baseStreamInput: ProcessAgentStreamEventInput = { // --------------------------------------------------------------------------- describe("processTimelineResponse", () => { + it("preserves the canonical end cursor on a projected assistant message", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + payload: { + ...baseTimelineInput.payload, + epoch: "timeline-1", + entries: [makeTimelineEntry(40, "[System Error] failed", "assistant_message", 42)], + }, + }); + + expect(result.tail).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("returns error path when payload.error is set", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -892,7 +911,12 @@ describe("processTimelineResponse", () => { }); it("merges assistant chunks across the older-page prepend boundary", () => { - const currentTail = [makeAssistantItem("newer chunk", "assistant-newer")]; + const currentTail = [ + { + ...makeAssistantItem("newer chunk", "assistant-newer"), + timelineCursor: { epoch: "epoch-1", seq: 3 }, + }, + ]; const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -914,6 +938,9 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]); + expect(result.tail[0]).toEqual( + expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }), + ); expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, @@ -1222,6 +1249,23 @@ describe("processTimelineResponse", () => { // --------------------------------------------------------------------------- describe("processAgentStreamEvent", () => { + it("preserves the live timeline cursor on an assistant error", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeAssistantTimelineEvent("[System Error] failed"), + epoch: "timeline-1", + seq: 42, + }); + + expect(result.head).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("passes through non-timeline events without cursor changes", () => { const turnEvent: AgentStreamEventPayload = { type: "turn_completed", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 22b0062f2..aaa0e21c0 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -510,6 +510,7 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea ...olderLast, text: `${olderLast.text}${currentFirst.text}`, timestamp: currentFirst.timestamp, + ...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}), }, ...currentTail.slice(1), ]; @@ -519,8 +520,9 @@ function replaceLiveAssistantWithProjectedText(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; + timelineCursor: { epoch: string; seq: number }; }): StreamItem[] | null { - const { head, event, timestamp } = params; + const { head, event, timestamp, timelineCursor } = params; if (event.type !== "timeline" || event.item.type !== "assistant_message") { return null; } @@ -537,6 +539,7 @@ function replaceLiveAssistantWithProjectedText(params: { ...current, text: event.item.text, timestamp, + timelineCursor, }; return next; } @@ -575,19 +578,22 @@ function applyTimelineIncrementalPath(args: { if (acceptedUnits.length > 0) { if (payload.direction === "before") { const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp }) => ({ + acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ event, timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })), { source: "canonical" }, ); nextTail = mergePrependedCanonicalTail(olderTail, currentTail); } else if (currentHead.length > 0) { - for (const { event, timestamp } of acceptedUnits) { + for (const { event, timestamp, seqEnd } of acceptedUnits) { + const timelineCursor = { epoch: payload.epoch, seq: seqEnd }; const replacedHead = replaceLiveAssistantWithProjectedText({ head: nextHead, event, timestamp, + timelineCursor, }); if (replacedHead) { nextHead = replacedHead; @@ -599,15 +605,17 @@ function applyTimelineIncrementalPath(args: { event, timestamp, source: "canonical", + timelineCursor, }); nextTail = applied.tail; nextHead = applied.head; } } else { nextTail = acceptedUnits.reduce( - (state, { event, timestamp }) => + (state, { event, timestamp, seqEnd }) => reduceStreamUpdate(state, event, timestamp, { source: "canonical", + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, }), currentTail, ); @@ -681,8 +689,16 @@ export function processTimelineResponse( const toHydratedEvents = ( units: TimelineUnit[], - ): Array<{ event: AgentStreamEventPayload; timestamp: Date }> => - units.map(({ event, timestamp }) => ({ event, timestamp })); + ): Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }> => + units.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) @@ -934,6 +950,10 @@ export function processAgentStreamEvent( input; const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); + const timelineCursor = + event.type === "timeline" && seq !== undefined && epoch !== undefined + ? { epoch, seq } + : undefined; // ------------------------------------------------------------------ // Apply stream event to tail/head @@ -945,6 +965,7 @@ export function processAgentStreamEvent( event, timestamp, source: "live", + timelineCursor, }) : { tail: currentTail, diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts new file mode 100644 index 000000000..079158b2d --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -0,0 +1,228 @@ +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; + +export interface ToolCallDescriptor { + detail: ToolCallDetail; + name: string; + status: "executing" | "running" | "completed" | "failed" | "canceled"; + error: unknown; + metadata?: Record; +} + +export interface ToolCallRun { + id: string; + calls: readonly ToolCallItem[]; + latest: ToolCallItem; + isSealed: boolean; +} + +export interface GroupedHistory { + tail: StreamItem[]; + groupsByHostId: Map; + pendingCalls: readonly ToolCallItem[]; +} + +export interface GroupedToolCalls { + tail: StreamItem[]; + head: StreamItem[]; + groupsByHostId: ToolCallGroupLookup; + historyGroupUpdatesByHostId: ToolCallGroupLookup; +} + +export interface ToolCallGroupLookup { + readonly size: number; + get(id: string): TGroup | undefined; + has(id: string): boolean; +} + +const EMPTY_GROUPS = new Map(); + +export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { + if (item.payload.source === "agent") { + const { data } = item.payload; + return { + detail: data.detail, + name: data.name, + status: data.status, + error: data.error, + metadata: data.metadata, + }; + } + + const { data } = item.payload; + return { + detail: { + type: "unknown", + input: data.arguments ?? null, + output: data.result ?? null, + }, + name: data.toolName, + status: data.status, + error: data.error, + }; +} + +export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { + if (item.kind !== "tool_call") { + return false; + } + const descriptor = describeToolCall(item); + return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; +} + +function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun { + const first = calls[0]; + const latest = calls.at(-1); + if (!first || !latest) { + throw new Error("Cannot group an empty tool call run"); + } + return { id: first.id, calls, latest, isSealed }; +} + +function createHost(run: ToolCallRun): ToolCallItem { + if (run.calls.length === 1) { + return run.latest; + } + return { ...run.latest, id: run.id }; +} + +function isRunning(call: ToolCallItem): boolean { + const status = describeToolCall(call).status; + return status === "running" || status === "executing"; +} + +function appendRun(input: { + calls: readonly ToolCallItem[]; + isSealed: boolean; + output: StreamItem[]; + groups: Map; + buildGroup: (run: ToolCallRun) => TGroup; +}): void { + if (input.calls.length === 0) { + return; + } + const run = createRun(input.calls, input.isSealed); + const host = createHost(run); + input.output.push(host); + input.groups.set(host.id, input.buildGroup(run)); +} + +export function prepareGroupedHistory(input: { + tail: StreamItem[]; + buildGroup: (run: ToolCallRun) => TGroup; +}): GroupedHistory { + const output: StreamItem[] = []; + const groups = new Map(); + let pending: ToolCallItem[] = []; + + for (const item of input.tail) { + if (isGroupableToolCall(item)) { + pending.push(item); + continue; + } + appendRun({ + calls: pending, + isSealed: true, + output, + groups, + buildGroup: input.buildGroup, + }); + pending = []; + output.push(item); + } + + appendRun({ + calls: pending, + isSealed: true, + output, + groups, + buildGroup: input.buildGroup, + }); + + return { + tail: groups.size > 0 ? output : input.tail, + groupsByHostId: groups, + pendingCalls: pending, + }; +} + +export function groupLiveToolCalls(input: { + history: GroupedHistory; + head: StreamItem[]; + isTurnActive: boolean; + buildGroup: (run: ToolCallRun) => TGroup; +}): GroupedToolCalls { + const head: StreamItem[] = []; + const liveGroups = new Map(); + let pending = [...input.history.pendingCalls]; + let hostPlacement: "history" | "head" | null = pending.length > 0 ? "history" : null; + let pendingIncludesHead = false; + + const flush = (isSealed: boolean) => { + if (pending.length === 0) { + return; + } + const run = createRun(pending, isSealed); + if (hostPlacement === "head") { + head.push(createHost(run)); + } + if (hostPlacement === "head" || pendingIncludesHead || !isSealed) { + liveGroups.set(run.id, input.buildGroup(run)); + } + pending = []; + hostPlacement = null; + pendingIncludesHead = false; + }; + + for (const item of input.head) { + if (isGroupableToolCall(item)) { + if (pending.length === 0) { + hostPlacement = "head"; + } + pending.push(item); + pendingIncludesHead = true; + continue; + } + flush(true); + head.push(item); + } + // Tool calls live in retained tail rather than the streaming head. The agent + // lifecycle snapshot can still be idle while a newly received tool call is + // already running, so its direct timeline status is the authoritative start + // signal. The lifecycle state continues to keep completed calls live between + // sequential tool updates. + const trailingRunIsActive = input.isTurnActive || pending.some(isRunning); + flush(!trailingRunIsActive); + + if (liveGroups.size === 0) { + return { + tail: input.history.tail, + head: input.head, + groupsByHostId: input.history.groupsByHostId, + historyGroupUpdatesByHostId: EMPTY_GROUPS, + }; + } + if (input.history.groupsByHostId.size === 0) { + return { + tail: input.history.tail, + head, + groupsByHostId: liveGroups, + historyGroupUpdatesByHostId: EMPTY_GROUPS, + }; + } + const groupsByHostId = new Map(input.history.groupsByHostId); + let historyGroupUpdatesByHostId: Map | null = null; + for (const [id, group] of liveGroups) { + groupsByHostId.set(id, group); + if (input.history.groupsByHostId.has(id)) { + historyGroupUpdatesByHostId ??= new Map(); + historyGroupUpdatesByHostId.set(id, group); + } + } + return { + tail: input.history.tail, + head, + groupsByHostId, + historyGroupUpdatesByHostId: historyGroupUpdatesByHostId ?? EMPTY_GROUPS, + }; +} diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts new file mode 100644 index 000000000..f41790461 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -0,0 +1,73 @@ +import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import { describeToolCall, type ToolCallRun } from "../grouping"; + +const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; +const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; + +export interface OverviewSummary { + editedFileCount: number; + commandCount: number; + readFileCount: number; + searchCount: number; + otherToolCount: number; + paseoCallCount: number; +} + +export interface OverviewToolCallGroup { + mode: "overview"; + run: ToolCallRun; + summary: OverviewSummary; + isLoading: boolean; +} + +function isPaseoCall(name: string, normalizedName: string): boolean { + return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); +} + +function isSearchCall(name: string): boolean { + return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); +} + +export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { + const editedFiles = new Set(); + const readFiles = new Set(); + let isLoading = false; + let commandCount = 0; + let searchCount = 0; + let otherToolCount = 0; + let paseoCallCount = 0; + + for (const call of run.calls) { + const descriptor = describeToolCall(call); + const normalizedName = descriptor.name.trim().toLowerCase(); + isLoading ||= descriptor.status === "running" || descriptor.status === "executing"; + if (isPaseoCall(descriptor.name, normalizedName)) { + paseoCallCount += 1; + } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { + editedFiles.add(descriptor.detail.filePath); + } else if (descriptor.detail.type === "shell") { + commandCount += 1; + } else if (descriptor.detail.type === "read") { + readFiles.add(descriptor.detail.filePath); + } else if (descriptor.detail.type === "search" || isSearchCall(normalizedName)) { + searchCount += 1; + } else { + otherToolCount += 1; + } + } + + const summary = { + editedFileCount: editedFiles.size, + commandCount, + readFileCount: readFiles.size, + searchCount, + otherToolCount, + paseoCallCount, + }; + return { + mode: "overview", + run, + isLoading, + summary, + }; +} diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx new file mode 100644 index 000000000..a878163a9 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -0,0 +1,108 @@ +import { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; +import { ScrollView } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Wrench } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ExpandableBadge } from "@/components/message"; +import { type OverviewSummary, type OverviewToolCallGroup } from "./model"; + +interface OverviewGroupProps { + group: OverviewToolCallGroup; + expanded: boolean; + isLastInSequence: boolean; + onExpandedChange: (groupId: string, expanded: boolean) => void; + children: ReactNode; +} + +const TOOL_CALL_GROUP_MAX_HEIGHT = 400; + +function joinSummaryParts(parts: string[], conjunction: string): string { + if (parts.length === 0) { + return ""; + } + let joined = parts[0] ?? ""; + if (parts.length === 2) { + joined = `${parts[0]} ${conjunction} ${parts[1]}`; + } else if (parts.length > 2) { + joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; + } + const firstCharacter = joined[0]; + return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; +} + +function useOverviewSummary(summary: OverviewSummary): string { + const { t } = useTranslation(); + return useMemo(() => { + const parts: string[] = []; + const entries = [ + [summary.editedFileCount, "toolCallGroup.editedFiles"], + [summary.commandCount, "toolCallGroup.commands"], + [summary.readFileCount, "toolCallGroup.readFiles"], + [summary.searchCount, "toolCallGroup.searches"], + [summary.otherToolCount, "toolCallGroup.otherTools"], + [summary.paseoCallCount, "toolCallGroup.paseoCalls"], + ] as const; + for (const [count, key] of entries) { + if (count > 0) { + parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); + } + } + return joinSummaryParts(parts, t("toolCallGroup.and")); + }, [summary, t]); +} + +export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({ + group, + expanded, + isLastInSequence, + onExpandedChange, + children, +}: OverviewGroupProps) { + const scrollRef = useRef(null); + const aggregateSummary = useOverviewSummary(group.summary); + const scrollToLatest = useCallback(() => { + scrollRef.current?.scrollToEnd({ animated: false }); + }, []); + const toggle = useCallback(() => { + onExpandedChange(group.run.id, !expanded); + }, [expanded, group.run.id, onExpandedChange]); + const renderDetails = useCallback( + () => ( + + {children} + + ), + [children, scrollToLatest], + ); + + return ( + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + scroll: { + maxHeight: TOOL_CALL_GROUP_MAX_HEIGHT, + }, + content: { + paddingTop: theme.spacing[1], + paddingHorizontal: 13, + }, +})); diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts new file mode 100644 index 000000000..c0e32c312 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, it } from "vitest"; +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, + type PreparedToolCallHistory, + type ToolCallDetailLevel, +} from "./projection"; + +type AssistantMessageItem = Extract; + +function toolCall( + id: string, + detail: ToolCallDetail, + options: { + name?: string; + status?: "running" | "completed" | "failed" | "canceled"; + } = {}, +): ToolCallItem { + return { + kind: "tool_call", + id, + timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`), + payload: { + source: "agent", + data: { + provider: "claude", + callId: id, + name: options.name ?? detail.type, + status: options.status ?? "completed", + error: options.status === "failed" ? "boom" : null, + detail, + }, + }, + }; +} + +function assistant(id: string): AssistantMessageItem { + return { + kind: "assistant_message", + id, + text: id, + timestamp: new Date("2026-01-01T00:01:00.000Z"), + }; +} + +function project(input: { + level: ToolCallDetailLevel; + tail?: StreamItem[]; + head?: StreamItem[]; + isTurnActive?: boolean; + preparedHistory?: PreparedToolCallHistory | null; +}) { + const tail = input.tail ?? []; + return projectToolCallDetailLevel({ + level: input.level, + tail, + head: input.head ?? [], + preparedHistory: input.preparedHistory ?? prepareToolCallHistory(input.level, tail), + isTurnActive: input.isTurnActive ?? false, + }); +} + +describe("tool call detail-level projection", () => { + it("passes detailed timelines through without grouping work", () => { + const tail = [toolCall("1", { type: "shell", command: "one" })]; + const head = [toolCall("2", { type: "shell", command: "two" })]; + + const prepared = prepareToolCallHistory("detailed", tail); + const result = project({ level: "detailed", tail, head, preparedHistory: prepared }); + + expect(prepared).toBeNull(); + expect(result.tail).toBe(tail); + expect(result.head).toBe(head); + expect(result.groupsByHostId.size).toBe(0); + }); + + it("keeps one stable overview host as a run grows", () => { + const firstCall = toolCall("1", { type: "shell", command: "one" }); + const secondCall = toolCall("2", { type: "read", filePath: "/repo/a.ts" }); + const prepared = prepareToolCallHistory("overview", []); + + const single = project({ + level: "overview", + head: [firstCall], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(single.head).toEqual([firstCall]); + expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ + calls: [firstCall], + latest: firstCall, + isSealed: false, + }); + + const grouped = project({ + level: "overview", + head: [firstCall, secondCall], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(grouped.head).toEqual([ + expect.objectContaining({ id: firstCall.id, timestamp: secondCall.timestamp }), + ]); + expect(grouped.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ + calls: [firstCall, secondCall], + latest: secondCall, + isSealed: false, + }); + }); + + it("keeps a parallel group loading while any call is still running", () => { + const calls = [ + toolCall("1", { type: "shell", command: "slow" }, { status: "running" }), + toolCall("2", { type: "shell", command: "done" }), + ]; + const result = project({ level: "overview", head: calls, isTurnActive: true }); + + expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); + }); + + it("builds a loading aggregate for a one-call run", () => { + const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" }); + const result = project({ level: "overview", head: [call], isTurnActive: true }); + const group = result.groupsByHostId.get(call.id); + if (!group) { + throw new Error("Expected an overview group"); + } + + expect(group).toMatchObject({ + isLoading: true, + summary: { commandCount: 1 }, + }); + }); + + it("keeps an active overview group on its latest call until a visible boundary arrives", () => { + const calls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }), + toolCall("3", { type: "read", filePath: "/repo/b.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/a.ts" }), + ]; + const prepared = prepareToolCallHistory("overview", []); + const active = project({ + level: "overview", + head: calls, + isTurnActive: true, + preparedHistory: prepared, + }); + const activeGroup = active.groupsByHostId.get("1"); + + expect(activeGroup).toMatchObject({ + mode: "overview", + run: { id: "1", latest: calls[3], isSealed: false }, + }); + const boundary = assistant("answer"); + const sealed = project({ + level: "overview", + head: [...calls, boundary], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(sealed.groupsByHostId.get("1")).toMatchObject({ + mode: "overview", + run: { latest: calls[3], isSealed: true }, + summary: { editedFileCount: 1, readFileCount: 2, commandCount: 1 }, + }); + }); + + it("keeps a running overview group live before the agent lifecycle catches up", () => { + const calls = ["1", "2", "3", "4"].map((id) => + toolCall(id, { type: "shell", command: id }, { status: "running" }), + ); + + const result = project({ + level: "overview", + tail: calls, + isTurnActive: false, + }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + run: { latest: calls[3], isSealed: false }, + isLoading: true, + summary: { commandCount: 4 }, + }); + }); + + it("seals the trailing overview group only when the turn ends", () => { + const calls = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id })); + const prepared = prepareToolCallHistory("overview", []); + + const betweenCalls = project({ + level: "overview", + head: calls, + isTurnActive: true, + preparedHistory: prepared, + }); + const nextCall = toolCall("5", { type: "read", filePath: "/repo/a.ts" }); + const continued = project({ + level: "overview", + head: [...calls, nextCall], + isTurnActive: true, + preparedHistory: prepared, + }); + const ended = project({ + level: "overview", + head: [...calls, nextCall], + isTurnActive: false, + preparedHistory: prepared, + }); + + expect(betweenCalls.groupsByHostId.get("1")?.run.isSealed).toBe(false); + expect(continued.groupsByHostId.get("1")?.run).toMatchObject({ + latest: nextCall, + isSealed: false, + }); + expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true); + }); + + it("builds overview summaries without category-specific presentation data", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("2", { type: "read", filePath: "/repo/src/b.ts" }), + toolCall("3", { type: "shell", command: "npm test" }), + toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }, { status: "failed" }), + ]; + + const overview = project({ level: "overview", head: calls }); + + expect(overview.groupsByHostId.get("1")).toEqual({ + mode: "overview", + run: expect.any(Object), + isLoading: false, + summary: { + editedFileCount: 1, + commandCount: 1, + readFileCount: 2, + searchCount: 0, + otherToolCount: 0, + paseoCallCount: 0, + }, + }); + }); + + it("distinguishes reads, searches, and other tools in overview", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }), + toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }), + toolCall( + "4", + { type: "search", query: "paseo", toolName: "web_search" }, + { status: "failed" }, + ), + toolCall("5", { type: "fetch", url: "not a url" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { + editedFileCount: 0, + commandCount: 0, + readFileCount: 2, + searchCount: 1, + otherToolCount: 2, + }, + }); + }); + + it("counts unique edited files and every shell command in overview", () => { + const calls = [ + toolCall("1", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("2", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("3", { type: "write", filePath: "/repo/b.ts" }), + toolCall("4", { type: "shell", command: "npm test" }), + toolCall("5", { type: "shell", command: "npm run lint" }), + toolCall("6", { type: "read", filePath: "/repo/c.ts" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { + editedFileCount: 2, + commandCount: 2, + readFileCount: 1, + otherToolCount: 0, + }, + }); + }); + + it("counts Paseo calls separately from other tools", () => { + const calls = [ + toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }), + toolCall( + "2", + { type: "unknown", input: null, output: null }, + { name: "mcp__paseo__list_worktrees" }, + ), + toolCall("3", { type: "fetch", url: "https://paseo.sh" }), + toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { otherToolCount: 2, paseoCallCount: 2 }, + }); + }); + + it("classifies direct Brave search and Paseo runtime tool names", () => { + const unknownDetail = { type: "unknown" as const, input: null, output: null }; + const calls = [ + toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }), + toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }), + toolCall("3", unknownDetail, { name: "paseo_list_providers" }), + toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 }, + }); + }); + + it("reuses prepared history and sealed group models across live-head updates", () => { + const historicalCalls = ["1", "2", "3", "4"].map((id) => + toolCall(id, { type: "shell", command: id }), + ); + const tail = [...historicalCalls, assistant("boundary")]; + const prepared = prepareToolCallHistory("overview", tail); + if (!prepared) { + throw new Error("Overview history must be prepared"); + } + expect(prepared.grouped.tail).toEqual([ + expect.objectContaining({ id: "1", timestamp: historicalCalls[3]?.timestamp }), + tail[4], + ]); + const first = project({ + level: "overview", + tail, + head: [toolCall("5", { type: "read", filePath: "/repo/a.ts" })], + isTurnActive: true, + preparedHistory: prepared, + }); + const second = project({ + level: "overview", + tail, + head: [ + toolCall("5", { type: "read", filePath: "/repo/a.ts" }), + toolCall("6", { type: "read", filePath: "/repo/b.ts" }), + ], + isTurnActive: true, + preparedHistory: prepared, + }); + + expect(first.tail).toBe(prepared.grouped.tail); + expect(second.tail).toBe(prepared.grouped.tail); + expect(first.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1")); + expect(second.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1")); + expect(first.historyGroupUpdatesByHostId.size).toBe(0); + expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId); + expect(second.groupsByHostId.get("5")?.run.calls).toHaveLength(2); + }); + + it("preserves projected history identity during assistant-only head updates", () => { + const trailingCalls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }), + ]; + const tail = [assistant("before"), ...trailingCalls]; + const prepared = prepareToolCallHistory("overview", tail); + if (!prepared) { + throw new Error("Overview history must be prepared"); + } + + const firstHead = [assistant("answer")]; + const secondHead = [{ ...firstHead[0], text: "answer grows" }]; + const first = project({ + level: "overview", + tail, + head: firstHead, + isTurnActive: true, + preparedHistory: prepared, + }); + const second = project({ + level: "overview", + tail, + head: secondHead, + isTurnActive: true, + preparedHistory: prepared, + }); + + expect(first.tail).toBe(prepared.grouped.tail); + expect(second.tail).toBe(prepared.grouped.tail); + expect(first.groupsByHostId).toBe(prepared.grouped.groupsByHostId); + expect(second.groupsByHostId).toBe(prepared.grouped.groupsByHostId); + expect(first.historyGroupUpdatesByHostId.size).toBe(0); + expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId); + }); + + it("forms one group across the retained-history and live-head boundary", () => { + const tail = [ + assistant("before"), + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "shell", command: "two" }), + ]; + const head = [ + toolCall("3", { type: "read", filePath: "/repo/a.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }), + ]; + + const result = project({ level: "overview", tail, head, isTurnActive: true }); + + expect(result.tail).toEqual([ + tail[0], + expect.objectContaining({ id: "1", timestamp: tail[2]?.timestamp }), + ]); + expect(result.head).toEqual([]); + expect(result.groupsByHostId.get("1")?.run).toMatchObject({ + calls: [...tail.slice(1), ...head], + latest: head[1], + isSealed: false, + }); + expect(result.historyGroupUpdatesByHostId.get("1")).toBe(result.groupsByHostId.get("1")); + }); + + it("keeps a trailing history-only group in the retained segment", () => { + const tail = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id })); + + const result = project({ level: "overview", tail, isTurnActive: false }); + + expect(result.tail).toEqual([ + expect.objectContaining({ id: "1", timestamp: tail[3]?.timestamp }), + ]); + expect(result.head).toEqual([]); + expect(result.groupsByHostId.get("1")?.run.isSealed).toBe(true); + }); + + it("hosts single calls while leaving plans and spoken messages ungrouped", () => { + const singleCall = toolCall("1", { type: "shell", command: "one" }); + const plan = toolCall("2", { type: "plan", text: "Plan" }); + const speak = toolCall( + "3", + { type: "unknown", input: "Hello", output: null }, + { name: "speak" }, + ); + + const result = project({ level: "overview", head: [singleCall, plan, speak] }); + + expect(result.head).toEqual([singleCall, plan, speak]); + expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]); + expect(result.groupsByHostId.size).toBe(1); + }); +}); diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts new file mode 100644 index 000000000..1740e8500 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -0,0 +1,60 @@ +import type { StreamItem } from "@/types/stream"; +import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +import { + groupLiveToolCalls, + prepareGroupedHistory, + type GroupedHistory, + type GroupedToolCalls, +} from "./grouping"; +import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model"; + +export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +export type ToolCallDetailGroup = OverviewToolCallGroup; + +export interface PreparedToolCallHistory { + mode: "overview"; + grouped: GroupedHistory; +} + +export interface ToolCallDetailProjection extends GroupedToolCalls {} + +const EMPTY_TOOL_CALL_GROUPS = new Map(); + +export function prepareToolCallHistory( + level: ToolCallDetailLevel, + tail: StreamItem[], +): PreparedToolCallHistory | null { + if (level === "detailed") { + return null; + } + return { + mode: "overview", + grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }), + }; +} + +export function projectToolCallDetailLevel(input: { + level: ToolCallDetailLevel; + tail: StreamItem[]; + head: StreamItem[]; + preparedHistory: PreparedToolCallHistory | null; + isTurnActive: boolean; +}): ToolCallDetailProjection { + if (input.level === "detailed") { + return { + tail: input.tail, + head: input.head, + groupsByHostId: EMPTY_TOOL_CALL_GROUPS, + historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS, + }; + } + if (!input.preparedHistory || input.preparedHistory.mode !== input.level) { + throw new Error(`Missing prepared ${input.level} tool call history`); + } + return groupLiveToolCalls({ + history: input.preparedHistory.grouped, + head: input.head, + isTurnActive: input.isTurnActive, + buildGroup: buildOverviewGroup, + }); +} diff --git a/packages/app/src/tool-calls/grouping.test.ts b/packages/app/src/tool-calls/grouping.test.ts deleted file mode 100644 index e88a28edc..000000000 --- a/packages/app/src/tool-calls/grouping.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; -import { compactToolCallRuns } from "./grouping"; - -function toolCall( - id: string, - detail: ToolCallDetail, - options: { - name?: string; - status?: "running" | "completed" | "failed" | "canceled"; - } = {}, -): ToolCallItem { - return { - kind: "tool_call", - id, - timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`), - payload: { - source: "agent", - data: { - provider: "claude", - callId: id, - name: options.name ?? detail.type, - status: options.status ?? "completed", - error: options.status === "failed" ? "boom" : null, - detail, - }, - }, - }; -} - -function assistant(id: string): StreamItem { - return { - kind: "assistant_message", - id, - text: id, - timestamp: new Date("2026-01-01T00:01:00.000Z"), - }; -} - -describe("compactToolCallRuns", () => { - it("preserves the original arrays when compaction is disabled", () => { - const tail = [toolCall("1", { type: "shell", command: "one" })]; - const head = [toolCall("2", { type: "shell", command: "two" })]; - - const result = compactToolCallRuns({ tail, head, enabled: false }); - - expect(result.tail).toBe(tail); - expect(result.head).toBe(head); - expect(result.groupsByHostId.size).toBe(0); - }); - - it("replaces four contiguous calls with a stable first-call host and latest timestamp", () => { - const calls = [ - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - toolCall("3", { type: "read", filePath: "/repo/src/a.ts" }), - toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ id: "1", timestamp: calls[3]?.timestamp }); - expect(result.head).toEqual([]); - expect(result.groupsByHostId.get("1")?.id).toBe("1"); - expect(result.groupsByHostId.get("1")?.calls).toEqual(calls); - expect(result.groupsByHostId.get("1")).toMatchObject({ - editedFileCount: 1, - commandCount: 2, - readFileCount: 1, - otherToolCount: 0, - categories: [ - { key: "shell", label: "Shell", callCount: 2, resources: [] }, - { key: "read", label: "Read", callCount: 1, resources: ["a.ts"] }, - { key: "edit", label: "Edit", callCount: 1, resources: ["a.ts"] }, - ], - }); - - const nextCall = toolCall("5", { type: "shell", command: "five" }, { status: "running" }); - const updated = compactToolCallRuns({ - tail: [...calls, nextCall], - head: [], - enabled: true, - }); - expect(updated.tail[0]).toMatchObject({ id: "1", timestamp: nextCall.timestamp }); - expect(updated.groupsByHostId.get("1")?.id).toBe("1"); - }); - - it("does not compact short runs and stops at visible content boundaries", () => { - const firstRun = [ - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - toolCall("3", { type: "shell", command: "three" }), - ]; - const boundary = assistant("assistant"); - const secondRun = [ - toolCall("4", { type: "read", filePath: "/repo/a.ts" }), - toolCall("5", { type: "read", filePath: "/repo/b.ts" }), - toolCall("6", { type: "read", filePath: "/repo/c.ts" }), - toolCall("7", { type: "read", filePath: "/repo/d.ts" }), - ]; - - const result = compactToolCallRuns({ - tail: [...firstRun, boundary, ...secondRun], - head: [], - enabled: true, - }); - - expect(result.tail.slice(0, -1)).toEqual([...firstRun, boundary]); - expect(result.tail.at(-1)).toMatchObject({ id: "4", timestamp: secondRun[3]?.timestamp }); - expect([...result.groupsByHostId.keys()]).toEqual(["4"]); - }); - - it("forms one group across the history and live-head boundary", () => { - const tail = [ - assistant("assistant"), - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - ]; - const head = [ - toolCall("3", { type: "read", filePath: "/repo/a.ts" }), - toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }), - ]; - - const result = compactToolCallRuns({ tail, head, enabled: true }); - - expect(result.tail).toEqual([tail[0]]); - expect(result.head).toHaveLength(1); - expect(result.head[0]).toMatchObject({ id: "1", timestamp: head[1]?.timestamp }); - expect(result.groupsByHostId.get("1")?.calls).toEqual([...tail.slice(1), ...head]); - expect(result.groupsByHostId.get("1")?.isRunning).toBe(true); - }); - - it("separates reads and searches from other tools", () => { - const calls = [ - toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), - toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }), - toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }), - toolCall( - "4", - { type: "search", query: "paseo", toolName: "web_search" }, - { status: "failed" }, - ), - toolCall("5", { type: "fetch", url: "not a url" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - const group = result.groupsByHostId.get("1"); - - expect(group).toMatchObject({ - failedCount: 1, - isRunning: false, - editedFileCount: 0, - commandCount: 0, - readFileCount: 2, - searchCount: 1, - otherToolCount: 2, - }); - }); - - it("counts unique edited files while counting each shell command", () => { - const calls = [ - toolCall("1", { type: "edit", filePath: "/repo/a.ts" }), - toolCall("2", { type: "edit", filePath: "/repo/a.ts" }), - toolCall("3", { type: "write", filePath: "/repo/b.ts" }), - toolCall("4", { type: "shell", command: "npm test" }), - toolCall("5", { type: "shell", command: "npm run lint" }), - toolCall("6", { type: "read", filePath: "/repo/c.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - editedFileCount: 2, - commandCount: 2, - readFileCount: 1, - otherToolCount: 0, - }); - }); - - it("counts Paseo calls separately from other tools", () => { - const calls = [ - toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }), - toolCall( - "2", - { type: "unknown", input: null, output: null }, - { name: "mcp__paseo__list_worktrees" }, - ), - toolCall("3", { type: "fetch", url: "https://paseo.sh" }), - toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - otherToolCount: 2, - paseoCallCount: 2, - }); - }); - - it("classifies direct Brave and Paseo runtime tool names", () => { - const unknownDetail = { type: "unknown" as const, input: null, output: null }; - const calls = [ - toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }), - toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }), - toolCall("3", unknownDetail, { name: "paseo_list_providers" }), - toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }), - toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }), - toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - searchCount: 3, - otherToolCount: 0, - paseoCallCount: 3, - }); - }); - - it("keeps plan and spoken-message calls outside compact groups", () => { - const shellCalls = ["1", "2", "3", "4"].map((id) => - toolCall(id, { type: "shell", command: id }), - ); - const plan = toolCall("5", { type: "plan", text: "Plan" }); - const speak = toolCall( - "6", - { type: "unknown", input: "Hello", output: null }, - { name: "speak" }, - ); - - const result = compactToolCallRuns({ - tail: [...shellCalls, plan, speak], - head: [], - enabled: true, - }); - - expect(result.tail[0]).toMatchObject({ id: "1", timestamp: shellCalls[3]?.timestamp }); - expect(result.tail.slice(1)).toEqual([plan, speak]); - expect(result.groupsByHostId.get("1")?.calls).toEqual(shellCalls); - }); - - it("deduplicates file resources by full path while displaying basenames", () => { - const calls = [ - toolCall("1", { type: "read", filePath: "/repo/src/index.ts" }), - toolCall("2", { type: "read", filePath: "/repo/tests/index.ts" }), - toolCall("3", { type: "read", filePath: "/repo/src/other.ts" }), - toolCall("4", { type: "read", filePath: "/repo/src/index.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - readFileCount: 3, - categories: [ - { - key: "read", - resources: ["index.ts", "index.ts", "other.ts"], - }, - ], - }); - }); -}); diff --git a/packages/app/src/tool-calls/grouping.ts b/packages/app/src/tool-calls/grouping.ts deleted file mode 100644 index f8fce5976..000000000 --- a/packages/app/src/tool-calls/grouping.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; -import { getFileNameFromPath } from "@/attachments/utils"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; -import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; -import { resolveToolCallIconName, type ToolCallIcon } from "@/utils/tool-call-icon-name"; - -export const MIN_COMPACT_TOOL_CALLS = 4; - -const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; -const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; - -export interface ToolCallCategorySummary { - key: string; - label: string; - iconName: ToolCallIcon; - callCount: number; - failedCount: number; - runningCount: number; - resources: string[]; -} - -export interface CompactToolCallGroup { - id: string; - calls: ToolCallItem[]; - callCount: number; - failedCount: number; - isRunning: boolean; - editedFileCount: number; - commandCount: number; - readFileCount: number; - searchCount: number; - otherToolCount: number; - paseoCallCount: number; - categories: ToolCallCategorySummary[]; -} - -export interface CompactToolCallRunsResult { - tail: StreamItem[]; - head: StreamItem[]; - groupsByHostId: Map; -} - -interface CompactToolCallRunsInput { - tail: StreamItem[]; - head: StreamItem[]; - enabled: boolean; -} - -interface TaggedStreamItem { - segment: "tail" | "head"; - item: StreamItem; -} - -interface ToolCallDescriptor { - detail: ToolCallDetail; - name: string; - status: "running" | "completed" | "failed" | "canceled"; - error: unknown; - metadata?: Record; -} - -interface ResourceSummary { - key: string; - label: string; -} - -function describeToolCall(item: ToolCallItem): ToolCallDescriptor { - if (item.payload.source === "agent") { - const { data } = item.payload; - return { - detail: data.detail, - name: data.name, - status: data.status, - error: data.error, - metadata: data.metadata, - }; - } - - const { data } = item.payload; - return { - detail: { - type: "unknown", - input: data.arguments ?? null, - output: data.result ?? null, - }, - name: data.toolName, - status: data.status === "executing" ? "running" : data.status, - error: data.error, - }; -} - -function isCompactableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { - return false; - } - const descriptor = describeToolCall(item); - return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; -} - -function isDirectPaseoToolName(name: string): boolean { - return name.startsWith(DIRECT_PASEO_TOOL_PREFIX); -} - -function isDirectSearchToolName(name: string): boolean { - return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); -} - -function resourceForDetail(detail: ToolCallDetail): ResourceSummary | null { - if (detail.type === "read" || detail.type === "edit" || detail.type === "write") { - return { - key: detail.filePath, - label: getFileNameFromPath(detail.filePath) ?? detail.filePath, - }; - } - if (detail.type !== "fetch") { - return null; - } - try { - const hostname = new URL(detail.url).hostname || detail.url; - return { key: hostname, label: hostname }; - } catch { - return { key: detail.url, label: detail.url }; - } -} - -function categoryIdentity(input: { - descriptor: ToolCallDescriptor; - normalizedName: string; - displayName: string; -}): { key: string; label: string; iconName: ToolCallIcon } { - if (isPaseoToolName(input.descriptor.name) || isDirectPaseoToolName(input.normalizedName)) { - return { key: "paseo", label: "Paseo", iconName: "paseo" }; - } - if ( - (input.descriptor.detail.type === "search" && - input.descriptor.detail.toolName === "web_search") || - isDirectSearchToolName(input.normalizedName) - ) { - return { key: "web_search", label: "Web search", iconName: "search" }; - } - if (input.descriptor.detail.type === "fetch") { - return { key: "fetch", label: "Web fetch", iconName: "search" }; - } - if (input.descriptor.detail.type !== "unknown" && input.descriptor.detail.type !== "plain_text") { - return { - key: input.descriptor.detail.type, - label: input.displayName, - iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), - }; - } - return { - key: `tool:${input.displayName.toLowerCase()}`, - label: input.displayName, - iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), - }; -} - -function buildCompactToolCallGroup(calls: ToolCallItem[]) { - const editedFiles = new Set(); - const readFiles = new Set(); - const categories = new Map(); - const categoryResourceKeys = new Map>(); - let failedCount = 0; - let isRunning = false; - let commandCount = 0; - let searchCount = 0; - let otherToolCount = 0; - let paseoCallCount = 0; - - for (const call of calls) { - const descriptor = describeToolCall(call); - const isFailed = descriptor.status === "failed"; - const isCallRunning = descriptor.status === "running"; - failedCount += isFailed ? 1 : 0; - isRunning ||= isCallRunning; - const normalizedName = descriptor.name.trim().toLowerCase(); - const display = buildToolCallDisplayModel({ - name: descriptor.name, - status: descriptor.status, - error: descriptor.error, - detail: descriptor.detail, - metadata: descriptor.metadata, - }); - const identity = categoryIdentity({ - descriptor, - normalizedName, - displayName: display.displayName, - }); - let category = categories.get(identity.key); - if (!category) { - category = { - ...identity, - callCount: 0, - failedCount: 0, - runningCount: 0, - resources: [], - }; - categories.set(identity.key, category); - } - category.callCount += 1; - category.failedCount += isFailed ? 1 : 0; - category.runningCount += isCallRunning ? 1 : 0; - const resource = resourceForDetail(descriptor.detail); - if (resource) { - let resourceKeys = categoryResourceKeys.get(identity.key); - if (!resourceKeys) { - resourceKeys = new Set(); - categoryResourceKeys.set(identity.key, resourceKeys); - } - if (!resourceKeys.has(resource.key)) { - resourceKeys.add(resource.key); - category.resources.push(resource.label); - } - } - - if (isPaseoToolName(descriptor.name) || isDirectPaseoToolName(normalizedName)) { - paseoCallCount += 1; - continue; - } - if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { - editedFiles.add(descriptor.detail.filePath); - continue; - } - if (descriptor.detail.type === "shell") { - commandCount += 1; - continue; - } - if (descriptor.detail.type === "read") { - readFiles.add(descriptor.detail.filePath); - continue; - } - if (descriptor.detail.type === "search" || isDirectSearchToolName(normalizedName)) { - searchCount += 1; - continue; - } - otherToolCount += 1; - } - - const firstCall = calls[0]; - if (!firstCall) { - throw new Error("Cannot build an empty tool call group"); - } - return { - id: firstCall.id, - calls, - callCount: calls.length, - failedCount, - isRunning, - editedFileCount: editedFiles.size, - commandCount, - readFileCount: readFiles.size, - searchCount, - otherToolCount, - paseoCallCount, - categories: [...categories.values()], - } satisfies CompactToolCallGroup; -} - -export function compactToolCallRuns(input: CompactToolCallRunsInput): CompactToolCallRunsResult { - if (!input.enabled) { - return { - tail: input.tail, - head: input.head, - groupsByHostId: new Map(), - }; - } - - const taggedItems: TaggedStreamItem[] = [ - ...input.tail.map((item) => ({ segment: "tail" as const, item })), - ...input.head.map((item) => ({ segment: "head" as const, item })), - ]; - const compactedTail: StreamItem[] = []; - const compactedHead: StreamItem[] = []; - const groupsByHostId = new Map(); - let pendingRun: TaggedStreamItem[] = []; - - const append = ({ segment, item }: TaggedStreamItem) => { - (segment === "tail" ? compactedTail : compactedHead).push(item); - }; - const flushRun = () => { - if (pendingRun.length >= MIN_COMPACT_TOOL_CALLS) { - const first = pendingRun[0]; - const latest = pendingRun.at(-1); - if (!first || !latest) { - throw new Error("Cannot compact an empty tool call run"); - } - const calls = pendingRun.map(({ item }) => item as ToolCallItem); - const stableHost: TaggedStreamItem = { - segment: latest.segment, - item: { ...latest.item, id: first.item.id }, - }; - append(stableHost); - groupsByHostId.set(stableHost.item.id, buildCompactToolCallGroup(calls)); - } else { - for (const entry of pendingRun) { - append(entry); - } - } - pendingRun = []; - }; - - for (const entry of taggedItems) { - if (isCompactableToolCall(entry.item)) { - pendingRun.push(entry); - continue; - } - flushRun(); - append(entry); - } - flushRun(); - - if (groupsByHostId.size === 0) { - return { - tail: input.tail, - head: input.head, - groupsByHostId, - }; - } - return { - tail: compactedTail, - head: compactedHead, - groupsByHostId, - }; -} diff --git a/packages/app/src/types/react-native-flat-list.d.ts b/packages/app/src/types/react-native-flat-list.d.ts new file mode 100644 index 000000000..29e82d1db --- /dev/null +++ b/packages/app/src/types/react-native-flat-list.d.ts @@ -0,0 +1,10 @@ +// React Native 0.81 implements FlatList's renderer memoization flag and ships +// it in generated types, but omits it from the legacy declarations exposed by +// the package entry point. +import "react-native"; + +declare module "react-native" { + interface FlatListProps { + strictMode?: boolean; + } +} diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index e059c0e98..13728a70e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -107,12 +107,18 @@ export interface AssistantMessageItem { kind: "assistant_message"; id: string; messageId?: string; + timelineCursor?: TimelinePosition; text: string; timestamp: Date; blockGroupId?: string; blockIndex?: number; } +export interface TimelinePosition { + epoch: string; + seq: number; +} + export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { @@ -202,6 +208,7 @@ export type StreamUpdateSource = "live" | "canonical"; interface StreamUpdateOptions { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet; + timelineCursor?: TimelinePosition; } function isRecord(value: unknown): value is Record { @@ -385,6 +392,7 @@ function appendAssistantMessage( source: StreamUpdateSource, messageId?: string, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -401,6 +409,7 @@ function appendAssistantMessage( ...last, text: `${last.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -1), updated]; } @@ -418,6 +427,7 @@ function appendAssistantMessage( ...secondLast, text: `${secondLast.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -2), updated, last]; } @@ -432,6 +442,7 @@ function appendAssistantMessage( kind: "assistant_message", id: entryId, ...(messageId ? { messageId } : {}), + ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, }; @@ -817,6 +828,7 @@ function reduceTimelineEvent( timestamp: Date, source: StreamUpdateSource, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const item = event.item; switch (item.type) { @@ -831,6 +843,7 @@ function reduceTimelineEvent( source, item.messageId, reservedItemIds, + timelineCursor, ), ); case "reasoning": @@ -876,7 +889,14 @@ export function reduceStreamUpdate( const source = options?.source ?? "live"; switch (event.type) { case "timeline": - return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds); + return reduceTimelineEvent( + state, + event, + timestamp, + source, + options?.reservedItemIds, + options?.timelineCursor, + ); case "thread_started": case "turn_started": case "turn_completed": @@ -898,11 +918,12 @@ export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date; + timelineCursor?: TimelinePosition; }>, options?: { source?: StreamUpdateSource }, ): StreamItem[] { - const hydrated = events.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp, options); + const hydrated = events.reduce((state, { event, timestamp, timelineCursor }) => { + return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor }); }, []); return finalizeActiveThoughts(hydrated); @@ -1186,6 +1207,7 @@ export function applyStreamEvent(params: { event: AgentStreamEventPayload; timestamp: Date; source?: StreamUpdateSource; + timelineCursor?: TimelinePosition; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; const source = params.source ?? "live"; @@ -1256,7 +1278,11 @@ export function applyStreamEvent(params: { ), ) : undefined; - const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds }); + const reduced = reduceStreamUpdate(nextHead, event, timestamp, { + source, + reservedItemIds, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextHead) { nextHead = reduced; changedHead = true; @@ -1275,7 +1301,10 @@ export function applyStreamEvent(params: { } // For non-streamable kinds or non-timeline events, apply to tail - const reduced = reduceStreamUpdate(nextTail, event, timestamp, { source }); + const reduced = reduceStreamUpdate(nextTail, event, timestamp, { + source, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextTail) { nextTail = reduced; changedTail = true; diff --git a/packages/app/src/utils/desktop-window.test.ts b/packages/app/src/utils/desktop-window.test.ts index 573faa917..c9f915905 100644 --- a/packages/app/src/utils/desktop-window.test.ts +++ b/packages/app/src/utils/desktop-window.test.ts @@ -1,107 +1,82 @@ import { describe, expect, it } from "vitest"; import { - resolveRawWindowControlsPadding, - resolveWindowControlsPadding, + intersectWindowChromeCorners, + resolveHasOwnedWindowChromeObstruction, + resolveWindowChromeObstruction, + resolveWindowChromeSafeArea, } from "@/utils/desktop-window"; -const rawPadding = { - left: 80, - right: 48, - top: 28, -}; - -describe("resolveWindowControlsPadding", () => { - it("keeps mac traffic-light padding available when the app window is not fullscreen", () => { +describe("window chrome", () => { + it("has no corner obstruction outside Electron or in fullscreen", () => { expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: false }), - ).toEqual({ - left: 78, - right: 0, - top: 45, - }); + resolveWindowChromeObstruction({ isElectron: false, isMac: true, isFullscreen: false }), + ).toEqual({ topLeft: null, topRight: null }); + expect( + resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: true }), + ).toEqual({ topLeft: null, topRight: null }); }); - it("keeps Windows and Linux window-control padding available when the app window is not fullscreen", () => { + it("places native controls in their physical top corner", () => { expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: false, isFullscreen: false }), - ).toEqual({ - left: 0, - right: 140, - top: 48, - }); + resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: false }), + ).toEqual({ topLeft: { width: 78, height: 45 }, topRight: null }); + expect( + resolveWindowChromeObstruction({ isElectron: true, isMac: false, isFullscreen: false }), + ).toEqual({ topLeft: null, topRight: { width: 140, height: 48 } }); }); - it("does not reserve window-control padding when the app window is fullscreen", () => { + it("insets and reserves only claimed corners", () => { + const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } }; expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: true }), - ).toEqual({ - left: 0, - right: 0, - top: 0, - }); + resolveWindowChromeSafeArea({ obstruction, corners: "top-left", placement: "inline" }), + ).toEqual({ paddingLeft: 80, paddingRight: 0 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "below" }), + ).toEqual({ height: 32 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "both", placement: "below" }), + ).toEqual({ height: 32 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "inline" }), + ).toEqual({ paddingLeft: 0, paddingRight: 48 }); }); - it("pads the main header for window controls when the app sidebar is closed", () => { + it("intersects identical and empty corner claims", () => { + expect(intersectWindowChromeCorners("both", "both")).toBe("both"); + expect(intersectWindowChromeCorners("top-left", "top-left")).toBe("top-left"); + expect(intersectWindowChromeCorners("none", "both")).toBe("none"); + expect(intersectWindowChromeCorners("both", "none")).toBe("none"); + expect(intersectWindowChromeCorners("both", "top-left")).toBe("top-left"); + expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right"); + expect(intersectWindowChromeCorners("top-left", "top-right")).toBe("none"); + }); + + it("reports an obstruction only when the surface owns its corner", () => { + const obstruction = { + topLeft: { width: 78, height: 45 }, + topRight: { width: 140, height: 48 }, + }; + expect( - resolveWindowControlsPadding({ - role: "header", - rawPadding, - sidebarClosed: true, - explorerOpen: false, - focusModeEnabled: false, + resolveHasOwnedWindowChromeObstruction({ + obstruction, + corners: "top-left", + corner: "top-left", }), - ).toEqual({ - left: 80, - right: 48, - top: 0, - }); - }); - - it("does not add left padding to detail headers with their own sidebar", () => { + ).toBe(true); expect( - resolveWindowControlsPadding({ - role: "detailHeader", - rawPadding, - sidebarClosed: true, - explorerOpen: false, - focusModeEnabled: false, + resolveHasOwnedWindowChromeObstruction({ + obstruction, + corners: "top-left", + corner: "top-right", }), - ).toEqual({ - left: 0, - right: 48, - top: 0, - }); - }); - - it("pads a focus-mode tab row away from mac traffic lights even when the sidebar is logically open", () => { + ).toBe(false); expect( - resolveWindowControlsPadding({ - role: "tabRow", - rawPadding, - sidebarClosed: false, - explorerOpen: false, - focusModeEnabled: true, + resolveHasOwnedWindowChromeObstruction({ + obstruction: { topLeft: null, topRight: null }, + corners: "both", + corner: "top-right", }), - ).toEqual({ - left: 80, - right: 48, - top: 0, - }); - }); - - it("pads a focus-mode tab row away from right-side window controls even when the explorer is logically open", () => { - expect( - resolveWindowControlsPadding({ - role: "tabRow", - rawPadding: { left: 0, right: 140, top: 48 }, - sidebarClosed: true, - explorerOpen: true, - focusModeEnabled: true, - }), - ).toEqual({ - left: 0, - right: 140, - top: 0, - }); + ).toBe(false); }); }); diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts deleted file mode 100644 index c939a5745..000000000 --- a/packages/app/src/utils/desktop-window.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { - getIsElectronRuntimeMac, - getIsElectronRuntime, - DESKTOP_TRAFFIC_LIGHT_WIDTH, - DESKTOP_TRAFFIC_LIGHT_HEIGHT, - DESKTOP_WINDOW_CONTROLS_WIDTH, - DESKTOP_WINDOW_CONTROLS_HEIGHT, -} from "@/constants/layout"; -import { getDesktopWindow } from "@/desktop/electron/window"; -import { usePanelStore } from "@/stores/panel-store"; -import { isNative } from "@/constants/platform"; - -interface RawWindowControlsPadding { - left: number; - right: number; - top: number; -} - -type WindowControlsPaddingRole = - | "sidebar" - | "header" - | "detailHeader" - | "tabRow" - | "explorerSidebar"; - -// Module-level cache so hook remounts (e.g., on navigation) don't briefly -// fall back to the default `false` while the async fullscreen check resolves. -// Without this, in fullscreen the sidebar flashes with traffic-light padding -// on first frame and then snaps to 0 once the async read completes. -let cachedIsFullscreen = false; -const fullscreenSubscribers = new Set<(value: boolean) => void>(); -let fullscreenSubscriptionStarted = false; - -function setCachedFullscreen(value: boolean) { - if (cachedIsFullscreen === value) return; - cachedIsFullscreen = value; - for (const sub of fullscreenSubscribers) { - sub(value); - } -} - -function startFullscreenSubscription() { - if (fullscreenSubscriptionStarted) return; - if (isNative || !getIsElectronRuntime()) return; - fullscreenSubscriptionStarted = true; - - void (async () => { - const win = getDesktopWindow(); - if (!win) return; - - if (typeof win.isFullscreen === "function") { - try { - setCachedFullscreen(await win.isFullscreen()); - } catch (error) { - console.warn("[DesktopWindow] Failed to read fullscreen state", error); - } - } - - if (typeof win.onResized !== "function") return; - - try { - await win.onResized(async () => { - if (typeof win.isFullscreen !== "function") return; - try { - setCachedFullscreen(await win.isFullscreen()); - } catch (error) { - console.warn("[DesktopWindow] Failed to read fullscreen state", error); - } - }); - } catch (error) { - console.warn("[DesktopWindow] Failed to subscribe to resize", error); - } - })(); -} - -function useRawWindowControlsPadding(): RawWindowControlsPadding { - const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen); - - useEffect(() => { - startFullscreenSubscription(); - // Sync to any value that resolved between render and effect. - setIsFullscreen(cachedIsFullscreen); - fullscreenSubscribers.add(setIsFullscreen); - return () => { - fullscreenSubscribers.delete(setIsFullscreen); - }; - }, []); - - return resolveRawWindowControlsPadding({ - isElectron: getIsElectronRuntime(), - isMac: getIsElectronRuntimeMac(), - isFullscreen, - }); -} - -export function resolveRawWindowControlsPadding(input: { - isElectron: boolean; - isMac: boolean; - isFullscreen: boolean; -}): RawWindowControlsPadding { - if (!input.isElectron || input.isFullscreen) { - return { left: 0, right: 0, top: 0 }; - } - - if (input.isMac) { - return { - left: DESKTOP_TRAFFIC_LIGHT_WIDTH, - right: 0, - top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, - }; - } - - return { - left: 0, - right: DESKTOP_WINDOW_CONTROLS_WIDTH, - top: DESKTOP_WINDOW_CONTROLS_HEIGHT, - }; -} - -export function useWindowControlsPadding(role: WindowControlsPaddingRole): { - left: number; - right: number; - top: number; -} { - const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen); - const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); - const rawPadding = useRawWindowControlsPadding(); - const sidebarClosed = !sidebarOpen; - - const { left, right, top } = resolveWindowControlsPadding({ - role, - rawPadding, - sidebarClosed, - explorerOpen, - focusModeEnabled, - }); - - return useMemo(() => ({ left, right, top }), [left, right, top]); -} - -export function resolveWindowControlsPadding(input: { - role: WindowControlsPaddingRole; - rawPadding: RawWindowControlsPadding; - sidebarClosed: boolean; - explorerOpen: boolean; - focusModeEnabled: boolean; -}): RawWindowControlsPadding { - if (input.role === "sidebar") { - return { - left: input.rawPadding.left, - right: 0, - top: input.rawPadding.top, - }; - } - - if (input.role === "header") { - return { - left: input.sidebarClosed ? input.rawPadding.left : 0, - right: input.explorerOpen ? 0 : input.rawPadding.right, - top: 0, - }; - } - - if (input.role === "detailHeader") { - return { - left: 0, - right: input.rawPadding.right, - top: 0, - }; - } - - if (input.role === "tabRow") { - return { - left: input.focusModeEnabled ? input.rawPadding.left : 0, - right: input.focusModeEnabled ? input.rawPadding.right : 0, - top: 0, - }; - } - - return { - left: 0, - right: input.rawPadding.right, - top: 0, - }; -} diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx new file mode 100644 index 000000000..d3c6a5cfd --- /dev/null +++ b/packages/app/src/utils/desktop-window.tsx @@ -0,0 +1,274 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; +import { + DESKTOP_TRAFFIC_LIGHT_HEIGHT, + DESKTOP_TRAFFIC_LIGHT_WIDTH, + DESKTOP_WINDOW_CONTROLS_HEIGHT, + DESKTOP_WINDOW_CONTROLS_WIDTH, + getIsElectronRuntime, + getIsElectronRuntimeMac, +} from "@/constants/layout"; +import { getDesktopWindow } from "@/desktop/electron/window"; +import { isNative } from "@/constants/platform"; + +export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both"; +type WindowChromeSafeAreaPlacement = "inline" | "below"; + +interface WindowChromeCornerObstruction { + width: number; + height: number; +} + +interface WindowChromeObstruction { + topLeft: WindowChromeCornerObstruction | null; + topRight: WindowChromeCornerObstruction | null; +} + +export type WindowChromeCorner = "top-left" | "top-right"; + +type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number }; + +const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null }; +const WindowChromeContext = createContext(EMPTY_OBSTRUCTION); +const WindowChromeCornersContext = createContext("none"); + +function windowChromeCornersFromFlags(topLeft: boolean, topRight: boolean): WindowChromeCorners { + if (topLeft && topRight) return "both"; + if (topLeft) return "top-left"; + if (topRight) return "top-right"; + return "none"; +} + +export function windowChromeCornersInclude( + corners: WindowChromeCorners, + corner: WindowChromeCorner, +): boolean { + return corners === "both" || corners === corner; +} + +export function resolveHasOwnedWindowChromeObstruction(input: { + obstruction: WindowChromeObstruction; + corners: WindowChromeCorners; + corner: WindowChromeCorner; +}): boolean { + if (!windowChromeCornersInclude(input.corners, input.corner)) return false; + return input.corner === "top-left" + ? input.obstruction.topLeft !== null + : input.obstruction.topRight !== null; +} + +export function useHasWindowChromeObstruction(corner: WindowChromeCorner): boolean { + const obstruction = useContext(WindowChromeContext); + return corner === "top-left" ? obstruction.topLeft !== null : obstruction.topRight !== null; +} + +export function intersectWindowChromeCorners( + inherited: WindowChromeCorners, + declared: WindowChromeCorners, +): WindowChromeCorners { + const inheritedTopLeft = inherited === "top-left" || inherited === "both"; + const inheritedTopRight = inherited === "top-right" || inherited === "both"; + const declaredTopLeft = declared === "top-left" || declared === "both"; + const declaredTopRight = declared === "top-right" || declared === "both"; + return windowChromeCornersFromFlags( + inheritedTopLeft && declaredTopLeft, + inheritedTopRight && declaredTopRight, + ); +} + +export function resolveWindowChromeObstruction(input: { + isElectron: boolean; + isMac: boolean; + isFullscreen: boolean; +}): WindowChromeObstruction { + if (!input.isElectron || input.isFullscreen) return EMPTY_OBSTRUCTION; + if (input.isMac) { + return { + topLeft: { width: DESKTOP_TRAFFIC_LIGHT_WIDTH, height: DESKTOP_TRAFFIC_LIGHT_HEIGHT }, + topRight: null, + }; + } + return { + topLeft: null, + topRight: { width: DESKTOP_WINDOW_CONTROLS_WIDTH, height: DESKTOP_WINDOW_CONTROLS_HEIGHT }, + }; +} + +export function resolveWindowChromeSafeArea(input: { + obstruction: WindowChromeObstruction; + corners: WindowChromeCorners; + placement: WindowChromeSafeAreaPlacement; +}): WindowChromeSafeAreaStyle { + const ownsTopLeft = input.corners === "top-left" || input.corners === "both"; + const ownsTopRight = input.corners === "top-right" || input.corners === "both"; + const topLeft = ownsTopLeft ? input.obstruction.topLeft : null; + const topRight = ownsTopRight ? input.obstruction.topRight : null; + if (input.placement === "below") { + return { height: Math.max(topLeft?.height ?? 0, topRight?.height ?? 0) }; + } + return { paddingLeft: topLeft?.width ?? 0, paddingRight: topRight?.width ?? 0 }; +} + +export function WindowChromeProvider({ children }: { children: ReactNode }) { + const [isElectronReady, setIsElectronReady] = useState(getIsElectronRuntime); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + let active = true; + let dispose: (() => void) | undefined; + let connecting = false; + let retryCount = 0; + let retryTimer: ReturnType | undefined; + + function scheduleRetry(warnOnExhaustion = false) { + if (!active || dispose || retryTimer) return; + if (retryCount >= 40) { + if (warnOnExhaustion) { + console.warn("[DesktopWindow] Chrome bridge unavailable; window controls may overlap UI"); + } + return; + } + retryCount += 1; + retryTimer = setTimeout(() => { + retryTimer = undefined; + connect(); + }, 250); + } + + function connect() { + if (!active || dispose || connecting) return; + if (!getIsElectronRuntime()) return scheduleRetry(); + const desktopWindow = getDesktopWindow(); + if ( + !desktopWindow || + typeof desktopWindow.isFullscreen !== "function" || + typeof desktopWindow.onResized !== "function" + ) + return scheduleRetry(true); + const readFullscreen = desktopWindow.isFullscreen; + const subscribeToResized = desktopWindow.onResized; + connecting = true; + void (async () => { + async function syncFullscreen() { + try { + const fullscreen = await readFullscreen(); + if (active) setIsFullscreen(fullscreen); + } catch (error) { + if (active) console.warn("[DesktopWindow] Failed to read fullscreen state", error); + } + } + try { + const nextDispose = await subscribeToResized(syncFullscreen); + if (!active) return nextDispose(); + dispose = nextDispose; + setIsElectronReady(true); + await syncFullscreen(); + } catch (error) { + if (active) console.warn("[DesktopWindow] Failed to subscribe to resize", error); + } finally { + connecting = false; + if (!dispose) scheduleRetry(); + } + })(); + } + + if (!isNative) connect(); + + return () => { + active = false; + if (retryTimer) clearTimeout(retryTimer); + dispose?.(); + }; + }, []); + + const obstruction = useMemo( + () => + resolveWindowChromeObstruction({ + isElectron: isElectronReady, + isMac: getIsElectronRuntimeMac(), + isFullscreen, + }), + [isElectronReady, isFullscreen], + ); + return ( + + + {children} + + + ); +} + +/** Narrows inherited corner ownership to the corners occupied by this child surface. */ +export function WindowChromeRegion({ + corners, + children, +}: { + corners: WindowChromeCorners; + children: ReactNode; +}) { + const inheritedCorners = useContext(WindowChromeCornersContext); + const ownedCorners = intersectWindowChromeCorners(inheritedCorners, corners); + return ( + + {children} + + ); +} + +/** Restarts ownership for a new physical viewport such as a Modal or full-window overlay. */ +export function WindowChromeRootRegion({ + corners, + children, +}: { + corners: WindowChromeCorners; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useWindowChromeCorners(): WindowChromeCorners { + return useContext(WindowChromeCornersContext); +} + +export function useOwnsWindowChromeCorner(corner: WindowChromeCorner): boolean { + const corners = useContext(WindowChromeCornersContext); + return windowChromeCornersInclude(corners, corner); +} + +export function useHasOwnedWindowChromeObstruction(corner: WindowChromeCorner): boolean { + const obstruction = useContext(WindowChromeContext); + const corners = useContext(WindowChromeCornersContext); + return resolveHasOwnedWindowChromeObstruction({ obstruction, corners, corner }); +} + +type WindowChromeSafeAreaProps = ViewProps & { + placement: WindowChromeSafeAreaPlacement; + horizontalPadding?: number; +}; + +export function WindowChromeSafeArea({ + placement, + horizontalPadding = 0, + style, + ...props +}: WindowChromeSafeAreaProps) { + const obstruction = useContext(WindowChromeContext); + const corners = useContext(WindowChromeCornersContext); + const safeAreaStyle = useMemo(() => { + const resolved = resolveWindowChromeSafeArea({ obstruction, corners, placement }); + if (placement === "below") return resolved; + const paddingLeft = "paddingLeft" in resolved ? resolved.paddingLeft : 0; + const paddingRight = "paddingRight" in resolved ? resolved.paddingRight : 0; + return { + paddingLeft: paddingLeft + horizontalPadding, + paddingRight: paddingRight + horizontalPadding, + }; + }, [corners, horizontalPadding, obstruction, placement]); + const combinedStyle = useMemo(() => [style, safeAreaStyle], [safeAreaStyle, style]); + return ; +} diff --git a/packages/app/src/utils/to-xterm-theme.ts b/packages/app/src/utils/to-xterm-theme.ts index c762bab1b..7c7d38043 100644 --- a/packages/app/src/utils/to-xterm-theme.ts +++ b/packages/app/src/utils/to-xterm-theme.ts @@ -12,7 +12,6 @@ export function toXtermTheme(terminal: TerminalPalette): ITheme { cursorAccent: terminal.cursorAccent, selectionBackground: terminal.selectionBackground, selectionForeground: terminal.selectionForeground, - black: terminal.black, red: terminal.red, green: terminal.green, diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts new file mode 100644 index 000000000..56aa83dc6 --- /dev/null +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runDeleteCommand } from "./delete.js"; + +const agent = { + id: "11111111-1111-4111-8111-111111111111", + status: "running", + archivedAt: null, + cwd: "/tmp/project", +}; +const cancelAgent = vi.fn(async () => { + throw new Error("active run cancellation was not acknowledged"); +}); +const deleteAgent = vi.fn(async () => undefined); +const close = vi.fn(async () => undefined); + +vi.mock("../../utils/client.js", () => ({ + connectToDaemon: vi.fn(async () => ({ + fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })), + fetchAgent: vi.fn(async () => ({ agent })), + cancelAgent, + deleteAgent, + close, + })), + getDaemonHost: vi.fn(() => "ws://127.0.0.1:6767"), +})); + +describe("runDeleteCommand", () => { + it("force-deletes a running agent when graceful cancellation is refused", async () => { + const result = await runDeleteCommand(agent.id, {}, {} as never); + + expect(cancelAgent).toHaveBeenCalledWith(agent.id); + expect(deleteAgent).toHaveBeenCalledWith(agent.id); + expect(result.data).toEqual({ + deletedCount: 1, + agentIds: [agent.id], + }); + }); +}); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index dafb26e4c..c8502c9a1 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -92,7 +92,7 @@ export async function runDeleteCommand( agents.map(async (agent) => { try { if (agent.status === "running") { - await client.cancelAgent(agent.id); + await client.cancelAgent(agent.id).catch(() => {}); } await client.deleteAgent(agent.id); return { ok: true as const, id: agent.id }; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1f827a0b3..fdb905b13 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -538,6 +538,7 @@ function normalizeListCommandsOptions( return { agentId: input, ...legacyOptions }; } export interface AgentForkContextOptions { + boundaryCursor?: FetchAgentTimelineCursor; boundaryMessageId?: string; requestId?: string; } @@ -2555,6 +2556,7 @@ export class DaemonClient { type: "agent.fork_context.request", agentId, requestId: resolvedRequestId, + ...(options.boundaryCursor ? { boundaryCursor: options.boundaryCursor } : {}), ...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}), }); @@ -2664,7 +2666,7 @@ export class DaemonClient { agentId, requestId, }); - await this.sendRequest({ + const payload = await this.sendRequest({ requestId, message, options: { skipQueue: true }, @@ -2678,6 +2680,9 @@ export class DaemonClient { return msg.payload; }, }); + if (payload.error) { + throw new Error(payload.error); + } } async setAgentMode(agentId: string, modeId: string): Promise { diff --git a/packages/desktop/capture-harness/index.html b/packages/desktop/capture-harness/index.html index 88dad2e1c..3befdcd94 100644 --- a/packages/desktop/capture-harness/index.html +++ b/packages/desktop/capture-harness/index.html @@ -80,29 +80,70 @@ ? Math.max(0, requestedWebviewCount) : 2; const webviews = []; + const profileAttachPromises = []; const activeTokens = new Set(); let nextTokenId = 0; let permanentParkingState = params.get("permanentParkingState") || ""; - function appendHarnessWebview(sourceUrl) { + function createHarnessWebview({ sourceUrl, browserId, partition }) { const index = webviews.length; const webview = document.createElement("webview"); webview.id = `target-webview-${index + 1}`; webview.className = "capture-harness-webview"; - webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`); - webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`); + webview.setAttribute("data-paseo-browser-id", browserId); + webview.setAttribute("partition", partition); webview.setAttribute("allowpopups", "true"); webview.setAttribute("spellcheck", "false"); webview.setAttribute("autosize", "on"); webview.src = sourceUrl; applyStackedWebviewStyle(webview); + return webview; + } + + function appendHarnessWebview(sourceUrl) { + const index = webviews.length; + const webview = createHarnessWebview({ + sourceUrl, + browserId: `capture-harness-${index + 1}`, + partition: `persist:paseo-capture-harness-${index + 1}`, + }); host.appendChild(webview); webviews.push(webview); return index; } - for (let index = 0; index < webviewCount; index += 1) { - appendHarnessWebview(params.get("targetUrl") || "bright.html"); + function appendProfileHarnessWebview({ browserId, partition, sourceUrl }) { + const webview = createHarnessWebview({ browserId, partition, sourceUrl }); + const attached = new Promise((resolve) => { + webview.addEventListener( + "did-attach", + () => resolve({ browserId, webContentsId: webview.getWebContentsId() }), + { once: true }, + ); + }); + host.appendChild(webview); + webviews.push(webview); + profileAttachPromises.push(attached); + return attached; + } + + const profilePartition = params.get("profilePartition"); + const profileBrowserIds = (params.get("profileBrowserIds") || "").split(",").filter(Boolean); + let profileAttachSequence = Promise.resolve(); + if (profilePartition && profileBrowserIds.length > 0) { + profileAttachSequence = (async () => { + for (const browserId of profileBrowserIds) { + await appendProfileHarnessWebview({ + browserId, + partition: profilePartition, + sourceUrl: params.get("targetUrl") || "bright.html", + }); + } + })(); + } else { + for (let index = 0; index < webviewCount; index += 1) { + appendHarnessWebview(params.get("targetUrl") || "bright.html"); + } } function applyHostParking() { @@ -363,6 +404,10 @@ addWebview(sourceUrl) { return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html"); }, + async profileIdentities() { + await profileAttachSequence; + return await Promise.all(profileAttachPromises); + }, addPermanentWebview(sourceUrl, stateName) { const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html"); applyPermanentParkingState(stateName || permanentParkingState); diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index a134a03b8..889423ca4 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1,8 +1,9 @@ const fs = require("node:fs"); const fsp = require("node:fs/promises"); +const http = require("node:http"); const path = require("node:path"); const { isDeepStrictEqual } = require("node:util"); -const { app, BrowserWindow, ipcMain, Menu, nativeImage, screen } = require("electron"); +const { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, session } = require("electron"); const ROOT = __dirname; const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out"); @@ -31,11 +32,15 @@ const VIEWPORT_WIDTH = 1280; const VIEWPORT_HEIGHT = 800; const FULL_PAGE_HEIGHT = 1600; const CAPTURE_TIMEOUT_MS = 5000; +const BROWSER_PROFILE_TIMEOUT_MS = 15000; const CAPTURE_RETRY_INTERVAL_MS = 200; const REPEAT_COUNT = 5; const FRESH_REPEAT_COUNT = 3; const SOAK_MS = Number(process.env.PASEO_CAPTURE_HARNESS_SOAK_MS || 75000); const HARNESS_GROUP = process.env.PASEO_CAPTURE_HARNESS_GROUP || "permanent-parking"; +const BROWSER_PROFILE_PHASE = process.env.PASEO_CAPTURE_HARNESS_PHASE || ""; +const BROWSER_PROFILE_ORIGIN_FILE = path.join(OUT_DIR, "browser-profile-origin.txt"); +const BROWSER_PROFILE_VALUE_FILE = path.join(OUT_DIR, "browser-profile-value.txt"); const PERMANENT_STATE_FILTER = new Set( (process.env.PASEO_CAPTURE_HARNESS_STATES || "P1") .split(",") @@ -143,6 +148,44 @@ function fileUrl(filePath, params = {}) { return url.toString(); } +async function startBrowserProfileServer() { + let port = 0; + if (BROWSER_PROFILE_PHASE === "read") { + const previousOrigin = (await fsp.readFile(BROWSER_PROFILE_ORIGIN_FILE, "utf8")).trim(); + port = Number(new URL(previousOrigin).port); + } + + const server = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("Shared browser profile

Profile fixture

"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("browser profile fixture server has no TCP address"); + } + const origin = `http://127.0.0.1:${address.port}`; + if (BROWSER_PROFILE_PHASE === "write") { + await fsp.writeFile(BROWSER_PROFILE_ORIGIN_FILE, `${origin}\n`); + } + return { origin, server }; +} + +async function closeServer(server) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + function ensureDirSync(dir) { fs.mkdirSync(dir, { recursive: true }); } @@ -182,12 +225,12 @@ async function waitForInactiveReveal(handle, label) { await delay(250); } -function withTimeout(promise, label) { +function withTimeout(promise, label, timeoutMs = CAPTURE_TIMEOUT_MS) { let timeoutId; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { - reject(new Error(`${label} timed out after ${CAPTURE_TIMEOUT_MS}ms`)); - }, CAPTURE_TIMEOUT_MS); + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => { clearTimeout(timeoutId); @@ -469,6 +512,7 @@ function installHarnessWebviewGuards(win, options = {}) { function trackAttachedGuests(win, input = {}) { const attachedGuests = []; const waiters = []; + const countWaiters = []; win.webContents.on("did-attach-webview", (_event, contents) => { if (input.disableGuestBackgroundThrottlingAtAttach) { contents.setBackgroundThrottling(false); @@ -478,6 +522,13 @@ function trackAttachedGuests(win, input = {}) { if (waiter) { waiter(contents); } + for (let index = countWaiters.length - 1; index >= 0; index -= 1) { + const countWaiter = countWaiters[index]; + if (attachedGuests.length >= countWaiter.count) { + countWaiters.splice(index, 1); + countWaiter.resolve(attachedGuests.slice(0, countWaiter.count)); + } + } }); return { attachedGuests, @@ -486,6 +537,14 @@ function trackAttachedGuests(win, input = {}) { waiters.push(resolve); }); }, + waitForAttachedGuests(count) { + if (attachedGuests.length >= count) { + return Promise.resolve(attachedGuests.slice(0, count)); + } + return new Promise((resolve) => { + countWaiters.push({ count, resolve }); + }); + }, }; } @@ -2372,12 +2431,193 @@ function assertAutomationSnapshot(snapshot) { } } +async function createBrowserProfileHarnessWindow(partition, sourceUrl) { + const handle = createInactiveHarnessWindow({ + width: 640, + height: 480, + backgroundColor: "#202020", + webPreferences: { + webviewTag: true, + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + const { win } = handle; + installHarnessWebviewGuards(win); + const tracker = trackAttachedGuests(win); + const guestsPromise = tracker.waitForAttachedGuests(2); + await withTimeout( + win.loadFile(path.join(ROOT, "index.html"), { + query: { + webviewCount: "2", + targetUrl: sourceUrl, + profilePartition: partition, + profileBrowserIds: "browser-first,browser-second", + }, + }), + "browser profile window loadFile", + ); + await waitForInactiveReveal(handle, "browser profile window"); + const [guests, identities] = await withTimeout( + Promise.all([guestsPromise, renderer(win, "window.captureHarness.profileIdentities()")]), + "browser profile did-attach", + BROWSER_PROFILE_TIMEOUT_MS, + ); + return { handle, guests, identities }; +} + +async function readBrowserProfileFixture(guest) { + return await guest.executeJavaScript(`({ + cookie: document.cookie, + localStorage: localStorage.getItem("paseo-browser-profile") + })`); +} + +function assertBrowserProfileFixture(state, expectedValue, label) { + if (state.localStorage !== expectedValue) { + fail(`${label} localStorage mismatch ${JSON.stringify(state)}`); + } + if (!state.cookie.split("; ").includes(`paseo-browser-profile=${expectedValue}`)) { + fail(`${label} cookie mismatch ${JSON.stringify(state)}`); + } +} + +function resolveBrowserProfileGuests(profileWindow, profileSession) { + if (profileWindow.identities.length !== 2 || profileWindow.guests.length !== 2) { + fail("browser profile harness did not attach exactly two guests"); + } + const guestsById = new Map(profileWindow.guests.map((guest) => [guest.id, guest])); + const [firstIdentity, secondIdentity] = profileWindow.identities; + const firstGuest = guestsById.get(firstIdentity.webContentsId); + const secondGuest = guestsById.get(secondIdentity.webContentsId); + if (!firstGuest || !secondGuest) { + fail("browser profile renderer identities did not map to attached main-process guests"); + } + if ( + firstIdentity.browserId !== "browser-first" || + firstIdentity.webContentsId !== firstGuest.id + ) { + fail( + `browser profile first attach mismatch ${JSON.stringify(firstIdentity)} main=${firstGuest.id}`, + ); + } + if ( + secondIdentity.browserId !== "browser-second" || + secondIdentity.webContentsId !== secondGuest.id + ) { + fail( + `browser profile second attach mismatch ${JSON.stringify(secondIdentity)} main=${secondGuest.id}`, + ); + } + if ( + firstGuest.hostWebContents !== profileWindow.handle.win.webContents || + secondGuest.hostWebContents !== profileWindow.handle.win.webContents + ) { + fail("browser profile guests were not owned by their renderer"); + } + if (firstGuest.session !== profileSession || secondGuest.session !== profileSession) { + fail("browser profile guests did not share the persistent session"); + } + return [firstGuest, secondGuest]; +} + +async function prepareBrowserProfileValue(firstGuest, profileSession) { + if (BROWSER_PROFILE_PHASE === "read") { + return (await fsp.readFile(BROWSER_PROFILE_VALUE_FILE, "utf8")).trim(); + } + + const profileValue = `profile-${Date.now()}-${process.pid}`; + await firstGuest.executeJavaScript(`(() => { + const value = ${JSON.stringify(profileValue)}; + localStorage.setItem("paseo-browser-profile", value); + document.cookie = "paseo-browser-profile=" + value + "; Max-Age=86400; SameSite=Lax"; + })()`); + if (BROWSER_PROFILE_PHASE === "write") { + await fsp.writeFile(BROWSER_PROFILE_VALUE_FILE, `${profileValue}\n`); + await profileSession.cookies.flushStore(); + } + return profileValue; +} + +async function runBrowserProfileGroup() { + if (!["write", "read"].includes(BROWSER_PROFILE_PHASE)) { + fail(`unknown browser profile phase ${BROWSER_PROFILE_PHASE}`); + } + const partition = "persist:paseo-browser-profile-harness-restart"; + const profileSession = session.fromPartition(partition); + const fixture = await startBrowserProfileServer(); + const windows = []; + try { + if (BROWSER_PROFILE_PHASE === "write") { + await profileSession.clearStorageData(); + await profileSession.clearCache(); + } + const profileWindow = await createBrowserProfileHarnessWindow(partition, fixture.origin); + windows.push(profileWindow.handle); + const [firstGuest, secondGuest] = resolveBrowserProfileGuests(profileWindow, profileSession); + await Promise.all([waitForGuestLoad(firstGuest), waitForGuestLoad(secondGuest)]); + + const profileValue = await prepareBrowserProfileValue(firstGuest, profileSession); + + const firstState = await readBrowserProfileFixture(firstGuest); + const secondState = await readBrowserProfileFixture(secondGuest); + assertBrowserProfileFixture(firstState, profileValue, "browser profile first tab"); + assertBrowserProfileFixture(secondState, profileValue, "browser profile second tab"); + + pass("browser profile renderer did-attach identities match their main-process guests"); + pass("browser profile tabs share cookies, localStorage, and one persistent session"); + if (BROWSER_PROFILE_PHASE === "read") { + pass("browser profile cookies and localStorage survived an Electron process restart"); + } + const results = [ + { group: "browser-profile", check: "renderer-main-identity", pass: true }, + { group: "browser-profile", check: "shared-profile-data", pass: true }, + ]; + if (BROWSER_PROFILE_PHASE === "read") { + results.push({ + group: "browser-profile", + check: "process-restart-persistence", + pass: true, + }); + } + return results; + } finally { + for (const handle of windows) { + await closeHarnessWindow(handle.win); + } + if (BROWSER_PROFILE_PHASE !== "write") { + await profileSession.clearStorageData(); + await profileSession.clearCache(); + } + await closeServer(fixture.server); + } +} + async function main() { ensureDirSync(OUT_DIR); - if (!["all", "existing", "permanent-parking", "automation"].includes(HARNESS_GROUP)) { + if ( + !["all", "existing", "permanent-parking", "automation", "browser-profile"].includes( + HARNESS_GROUP, + ) + ) { fail(`unknown harness group ${HARNESS_GROUP}`); } + if (HARNESS_GROUP === "browser-profile") { + const browserProfileResults = await runBrowserProfileGroup(); + await fsp.writeFile( + path.join(OUT_DIR, "results.json"), + `${JSON.stringify( + { generatedAt: new Date().toISOString(), browserProfileResults }, + null, + 2, + )}\n`, + ); + pass(`capture harness browser-profile complete output=${OUT_DIR}`); + return; + } + if (HARNESS_GROUP === "automation") { const automationResults = await runAutomationGroup(); await fsp.writeFile( diff --git a/packages/desktop/capture-harness/run.sh b/packages/desktop/capture-harness/run.sh index fc1bf139b..f668ffc15 100755 --- a/packages/desktop/capture-harness/run.sh +++ b/packages/desktop/capture-harness/run.sh @@ -3,5 +3,14 @@ set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) +ELECTRON="$REPO_ROOT/node_modules/.bin/electron" -exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js" +if [ "${PASEO_CAPTURE_HARNESS_GROUP:-}" = "browser-profile" ] && [ -z "${PASEO_CAPTURE_HARNESS_PHASE:-}" ]; then + PASEO_CAPTURE_HARNESS_PHASE=write "$ELECTRON" "$SCRIPT_DIR/main.js" + # Give Chromium's profile helpers time to release the persistent session before reopening it. + sleep 1 + PASEO_CAPTURE_HARNESS_PHASE=read "$ELECTRON" "$SCRIPT_DIR/main.js" + exit +fi + +exec "$ELECTRON" "$SCRIPT_DIR/main.js" diff --git a/packages/desktop/package.json b/packages/desktop/package.json index eb916638c..bcbe566cc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,6 +20,7 @@ "capture-harness": "./capture-harness/run.sh", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", + "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "vitest run", "typecheck": "tsgo --noEmit -p tsconfig.json" }, diff --git a/packages/desktop/scripts/verify-electron-cdp.mjs b/packages/desktop/scripts/verify-electron-cdp.mjs index 0a795c2a7..d37e13f41 100644 --- a/packages/desktop/scripts/verify-electron-cdp.mjs +++ b/packages/desktop/scripts/verify-electron-cdp.mjs @@ -3,9 +3,11 @@ import path from "node:path"; import process from "node:process"; import { chromium } from "playwright"; -const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223"; +const CDP_PORT = process.env.PASEO_ELECTRON_REMOTE_DEBUGGING_PORT ?? "9223"; +const EXPO_PORT = process.env.EXPO_PORT ?? "8082"; +const CDP_URL = process.env.CDP_URL ?? `http://127.0.0.1:${CDP_PORT}`; const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification"; -const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081"; +const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? `localhost:${EXPO_PORT}`; const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"]; const INTERACTIVE_SELECTOR = [ "button", @@ -32,10 +34,6 @@ function assert(condition, message) { } } -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - async function ensureDir(dirPath) { await fs.mkdir(dirPath, { recursive: true }); } @@ -46,6 +44,96 @@ async function captureScreenshot(page, fileName) { return filePath; } +function rectsIntersect(left, right) { + return ( + left.left < right.left + right.width && + left.left + left.width > right.left && + left.top < right.top + right.height && + left.top + left.height > right.top + ); +} + +function getWindowChromeObstruction(platform, innerWidth) { + if (platform === "darwin") { + return { corner: "top-left", left: 0, top: 0, width: 78, height: 45 }; + } + return { + corner: "top-right", + left: innerWidth - 140, + top: 0, + width: 140, + height: 48, + }; +} + +async function inspectSettingsGeometry(page) { + return page.evaluate(() => { + function rect(selector) { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) return null; + const bounds = element.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + }; + } + + const title = document.querySelector('[data-testid="settings-detail-header-title"]'); + const headerLeft = title instanceof HTMLElement ? title.parentElement : null; + const headerLeftBounds = headerLeft?.getBoundingClientRect() ?? null; + + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + sidebarRect: rect('[data-testid="settings-sidebar"]'), + detailPaneRect: rect('[data-testid="settings-detail-pane"]'), + outerAppSidebarSettingsRect: rect('[data-testid="sidebar-settings"]'), + backButtonRect: rect('[data-testid="settings-back-to-workspace"]'), + detailTitleRect: rect('[data-testid="settings-detail-header-title"]'), + detailHeaderLeftRect: headerLeftBounds + ? { + left: headerLeftBounds.left, + top: headerLeftBounds.top, + width: headerLeftBounds.width, + height: headerLeftBounds.height, + } + : null, + }; + }); +} + +function settingsGeometryClearsWindowChrome(geometry, platform) { + const obstruction = getWindowChromeObstruction(platform, geometry.innerWidth); + const consumer = platform === "darwin" ? geometry.backButtonRect : geometry.detailHeaderLeftRect; + return Boolean(consumer && !rectsIntersect(consumer, obstruction)); +} + +async function readBridgeFullscreen(page) { + return page.evaluate( + async () => + (await window.paseoDesktop?.window?.getCurrentWindow?.()?.isFullscreen?.()) === true, + ); +} + +async function setNativeFullscreen(page, fullscreen) { + await page.evaluate(async (nextFullscreen) => { + const win = window.paseoDesktop?.window?.getCurrentWindow?.(); + if (typeof win?.setFullscreen !== "function") throw new Error("setFullscreen is unavailable"); + await win.setFullscreen(nextFullscreen); + }, fullscreen); +} + +async function waitForBridgeFullscreen(page, expected) { + for (let attempt = 0; attempt < 50; attempt += 1) { + if ((await readBridgeFullscreen(page)) === expected) return; + await page.waitForTimeout(200); + } + throw new Error(`Timed out waiting for fullscreen=${expected}`); +} + async function inspectTitlebarRegions(page) { return page.evaluate((interactiveSelector) => { const nodes = Array.from(document.querySelectorAll("*")); @@ -259,111 +347,152 @@ async function inspectTitlebarRegions(page) { }, INTERACTIVE_SELECTOR); } -async function inspectFullscreenResizer(page) { - const session = await page.context().newCDPSession(page); - let windowId = null; - let initialBounds = null; - let fullscreenEntered = false; +async function inspectFullscreenWindowChrome(page, platform) { + const initiallyFullscreen = await readBridgeFullscreen(page); try { - const windowInfo = await session.send("Browser.getWindowForTarget"); - windowId = windowInfo.windowId; - initialBounds = await session.send("Browser.getWindowBounds", { windowId }); - await session.send("Browser.setWindowBounds", { - windowId, - bounds: { windowState: "fullscreen" }, - }); - fullscreenEntered = true; - await page.waitForTimeout(1000); + assert(!initiallyFullscreen, "Electron verifier requires a non-fullscreen QA window"); + const before = await inspectSettingsGeometry(page); + await setNativeFullscreen(page, true); + await waitForBridgeFullscreen(page, true); const details = await page.evaluate(async () => { - const bridge = window.paseoDesktop?.window; + const bridge = window.paseoDesktop?.window?.getCurrentWindow?.(); const bridgeFullscreen = typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null; - const visibleNoDragResizers = Array.from(document.querySelectorAll("*")) - .filter((node) => node instanceof HTMLElement) - .filter((node) => { - const element = node; - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - const appRegion = - style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none"; - return ( - rect.width > 0 && - rect.height > 0 && - style.display !== "none" && - style.visibility !== "hidden" && - style.opacity !== "0" && - appRegion === "no-drag" && - style.position === "absolute" && - Math.abs(rect.height - 4) <= 1 && - rect.top < 220 - ); - }) - .map((node) => { - const rect = node.getBoundingClientRect(); - return { - tagName: node.tagName.toLowerCase(), - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - }; - }); - - return { - bridgeFullscreen, - visibleNoDragResizers, - }; + return { bridgeFullscreen }; }); + const fullscreen = await inspectSettingsGeometry(page); + const screenshot = await captureScreenshot(page, "04-fullscreen-window-chrome.png"); + const clearanceRemoved = + platform === "darwin" + ? Boolean( + before.backButtonRect && + fullscreen.backButtonRect && + before.backButtonRect.top >= 45 && + fullscreen.backButtonRect.top < 45 && + fullscreen.backButtonRect.top < before.backButtonRect.top, + ) + : Boolean( + before.detailHeaderLeftRect && + fullscreen.detailHeaderLeftRect && + before.innerWidth - + (before.detailHeaderLeftRect.left + before.detailHeaderLeftRect.width) >= + 140 && + fullscreen.innerWidth - + (fullscreen.detailHeaderLeftRect.left + fullscreen.detailHeaderLeftRect.width) < + 40, + ); return { supported: true, - enteredFullscreen: fullscreenEntered, - initialBounds, + initiallyFullscreen, + before, + fullscreen, + clearanceRemoved, + screenshot, ...details, - passed: - details.bridgeFullscreen === true && - Array.isArray(details.visibleNoDragResizers) && - details.visibleNoDragResizers.length === 0, + passed: details.bridgeFullscreen === true && clearanceRemoved, }; } catch (error) { return { supported: false, error: String(error), - initialBounds, + initiallyFullscreen, }; } finally { - const previousWindowState = initialBounds?.bounds?.windowState ?? "normal"; - if (windowId !== null && fullscreenEntered) { - try { - await session.send("Browser.setWindowBounds", { - windowId, - bounds: { windowState: previousWindowState }, - }); - await page.waitForTimeout(500); - } catch { - // Best-effort restore only. - } + if (await readBridgeFullscreen(page)) { + await setNativeFullscreen(page, false); + await waitForBridgeFullscreen(page, false); } - await session.detach().catch(() => undefined); + } +} + +async function inspectHalfScreenSettingsLayout(page, platform) { + const initialBounds = await page.evaluate(() => ({ + width: window.outerWidth, + height: window.outerHeight, + })); + + try { + await page.evaluate(() => { + // Electron applies resizeTo to the native BrowserWindow. Unlike + // page.setViewportSize, this exercises the real window/layout boundary. + window.resizeTo(751, Math.max(window.outerHeight, 700)); + }); + await page.waitForFunction(() => window.innerWidth === 751, undefined, { timeout: 10_000 }); + + const sidebar = page.getByTestId("settings-sidebar"); + const detail = page.getByTestId("settings-detail-pane"); + const outerAppSidebarSettings = page.getByTestId("sidebar-settings"); + await sidebar.waitFor({ state: "visible", timeout: 10_000 }); + await detail.waitFor({ state: "visible", timeout: 10_000 }); + await outerAppSidebarSettings.waitFor({ state: "hidden", timeout: 10_000 }); + + const details = await inspectSettingsGeometry(page); + const obstruction = getWindowChromeObstruction(platform, details.innerWidth); + const clearsWindowChrome = settingsGeometryClearsWindowChrome(details, platform); + const sidebarRight = details.sidebarRect + ? details.sidebarRect.left + details.sidebarRect.width + : null; + const detailRight = details.detailPaneRect + ? details.detailPaneRect.left + details.detailPaneRect.width + : null; + const screenshot = await captureScreenshot(page, "06-half-screen-settings.png"); + + return { + supported: true, + initialBounds, + ...details, + obstruction, + clearsWindowChrome, + screenshot, + passed: + details.innerWidth === 751 && + details.sidebarRect !== null && + details.sidebarRect.width >= 300 && + details.detailPaneRect !== null && + details.detailPaneRect.width >= 400 && + details.outerAppSidebarSettingsRect === null && + Math.abs(details.sidebarRect.left) <= 1 && + sidebarRight !== null && + Math.abs(sidebarRight - details.detailPaneRect.left) <= 1 && + detailRight !== null && + Math.abs(detailRight - details.innerWidth) <= 1 && + clearsWindowChrome, + }; + } catch (error) { + return { supported: false, initialBounds, error: String(error) }; + } finally { + await page.evaluate((bounds) => window.resizeTo(bounds.width, bounds.height), initialBounds); + await page.waitForFunction( + (width) => Math.abs(window.outerWidth - width) <= 1, + initialBounds.width, + { timeout: 10_000 }, + ); } } async function findAppPage(browser) { - function findMatchingPage() { + function findMatchingPages() { + const matches = []; for (const context of browser.contexts()) { for (const page of context.pages()) { if (page.url().includes(APP_URL_FRAGMENT) && !page.url().startsWith("devtools://")) { - return page; + matches.push(page); } } } - return null; + return matches; } async function poll(attempt) { - const page = findMatchingPage(); - if (page) return page; + const pages = findMatchingPages(); + if (pages.length > 1) { + throw new Error( + `Expected one Electron QA page for ${APP_URL_FRAGMENT}, found ${pages.length}`, + ); + } + if (pages.length === 1) return pages[0]; if (attempt >= 29) { throw new Error(`Unable to find Electron app page for ${APP_URL_FRAGMENT}`); } @@ -412,24 +541,46 @@ async function navigateToSettings(page, serverId) { await page.evaluate((nextServerId) => { window.location.href = `/h/${nextServerId}/settings`; }, serverId); - await page.waitForURL(new RegExp(`/h/${escapeRegExp(serverId)}/settings$`), { - timeout: 30_000, - }); - await page.getByText("Daemon management", { exact: true }).waitFor({ - timeout: 30_000, - }); + await page.getByTestId("settings-sidebar").waitFor({ state: "visible", timeout: 30_000 }); + await page + .getByTestId("settings-detail-header-title") + .waitFor({ state: "visible", timeout: 30_000 }); } -async function dismissMobileSidebarIfVisible(page) { +async function dismissOuterAppSidebarIfVisible(page) { const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first(); const menuToggle = page.locator('[data-testid="menu-button"]').first(); const bothVisible = (await sidebarSettingsButton.isVisible().catch(() => false)) && (await menuToggle.isVisible().catch(() => false)); - if (!bothVisible) return; + if (!bothVisible) return false; await menuToggle.click(); - await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => undefined); + await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }); await page.waitForTimeout(500); + return true; +} + +async function restoreOuterAppSidebar(page, wasDismissed) { + if (!wasDismissed) return; + const menuToggle = page.locator('[data-testid="menu-button"]').first(); + const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first(); + await menuToggle.click(); + await sidebarSettingsButton.waitFor({ state: "visible", timeout: 10_000 }); +} + +async function clearTitlebarAnnotations(page) { + await page.evaluate(() => { + document.getElementById("electron-verify-titlebar-style")?.remove(); + for (const attribute of [ + "data-electron-verify-drag", + "data-electron-verify-resizer", + "data-electron-verify-interactive", + ]) { + for (const element of document.querySelectorAll(`[${attribute}]`)) { + element.removeAttribute(attribute); + } + } + }); } function evaluateDragRegionCheck(dragRegionCheck) { @@ -444,13 +595,13 @@ function evaluateDragRegionCheck(dragRegionCheck) { ); } -function evaluateTrafficLightPadding(dragRegionCheck) { - if (process.platform !== "darwin") return true; - const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null; - return ( - typeof observedPaddingLeft === "number" && - observedPaddingLeft >= 78 && - observedPaddingLeft <= 110 +function evaluateTrafficLightAvoidance(dragRegionCheck) { + const firstInteractive = dragRegionCheck.candidate?.explicitNoDragInteractive?.find( + (entry) => entry.testId === "settings-back-to-workspace", + ); + return Boolean( + firstInteractive && + !rectsIntersect(firstInteractive, { left: 0, top: 0, width: 78, height: 45 }), ); } @@ -462,14 +613,22 @@ async function collectDragRegionResults(page, dragRegionCheck, dragScreenshot, r screenshot: dragScreenshot, }); - const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png"); + const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-avoidance.png"); + const firstInteractive = dragRegionCheck.candidate?.explicitNoDragInteractive?.find( + (entry) => entry.testId === "settings-back-to-workspace", + ); results.push({ - check: "traffic-light-padding", - pass: evaluateTrafficLightPadding(dragRegionCheck), + check: "traffic-light-avoidance", + pass: process.platform === "darwin" ? evaluateTrafficLightAvoidance(dragRegionCheck) : true, + skipped: process.platform !== "darwin", details: { platform: process.platform, - observedPaddingLeft: dragRegionCheck.candidate?.parent?.paddingLeft ?? null, - note: "Traffic-light padding is only validated structurally on macOS in this verifier.", + obstruction: process.platform === "darwin" ? { width: 78, height: 45 } : null, + firstInteractive: firstInteractive ?? null, + note: + process.platform === "darwin" + ? "The first interactive sidebar row must not intersect the traffic-light rectangle." + : "Skipped here; the half-screen check exercises the right-side obstruction on Windows/Linux.", candidate: dragRegionCheck.candidate, }, screenshot: trafficLightScreenshot, @@ -489,25 +648,28 @@ async function collectDragRegionResults(page, dragRegionCheck, dragScreenshot, r }); } -async function collectDaemonManagementResult(page, serverId, desktopStatus, results) { - const daemonManagementVisible = await Promise.all([ - page.getByText("Built-in daemon", { exact: true }).isVisible(), - page.getByText("Daemon management", { exact: true }).isVisible(), - page.getByRole("button", { name: "Restart daemon" }).first().isVisible(), - ]).then((values) => values.every(Boolean)); - const daemonManagementScreenshot = await captureScreenshot( - page, - "05-settings-daemon-management.png", - ); +async function collectSettingsSplitResult(page, serverId, desktopStatus, results) { + const geometry = await inspectSettingsGeometry(page); + const sidebarRight = geometry.sidebarRect + ? geometry.sidebarRect.left + geometry.sidebarRect.width + : null; + const settingsScreenshot = await captureScreenshot(page, "05-settings-split.png"); results.push({ - check: "settings-daemon-management", - pass: daemonManagementVisible, + check: "settings-split", + pass: Boolean( + geometry.sidebarRect && + geometry.detailPaneRect && + geometry.detailTitleRect && + sidebarRight !== null && + Math.abs(sidebarRight - geometry.detailPaneRect.left) <= 1, + ), details: { route: page.url(), serverId, desktopStatus, + geometry, }, - screenshot: daemonManagementScreenshot, + screenshot: settingsScreenshot, }); } @@ -515,77 +677,108 @@ async function main() { await ensureDir(OUTPUT_DIR); const browser = await chromium.connectOverCDP(CDP_URL); - const page = await findAppPage(browser); - const consoleMessages = []; - const results = []; + let page = null; + let initialPageUrl = null; + let outerSidebarDismissed = false; - attachConsoleCollector(page, consoleMessages); - await navigateToWelcome(page); + try { + page = await findAppPage(browser); + initialPageUrl = page.url(); + const consoleMessages = []; + const results = []; - const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png"); - const desktopDetection = await detectDesktopBridge(page); + attachConsoleCollector(page, consoleMessages); + await navigateToWelcome(page); - const hasExpectedDesktopShape = - desktopDetection.exists && - REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key)); + const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png"); + const desktopDetection = await detectDesktopBridge(page); - results.push({ - check: "desktop-detection", - pass: hasExpectedDesktopShape, - details: desktopDetection, - screenshot: welcomeScreenshot, - }); + const hasExpectedDesktopShape = + desktopDetection.exists && + REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key)); + assert( + ["darwin", "win32", "linux"].includes(desktopDetection.platform), + `Unexpected Electron platform: ${desktopDetection.platform}`, + ); - const desktopStatus = await page.evaluate(() => - window.paseoDesktop.invoke("desktop_daemon_status"), - ); - assert( - typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0, - "desktop_daemon_status did not return a serverId", - ); + results.push({ + check: "desktop-detection", + pass: hasExpectedDesktopShape, + details: desktopDetection, + screenshot: welcomeScreenshot, + }); - const serverId = desktopStatus.serverId.trim(); - await navigateToSettings(page, serverId); + const desktopStatus = await page.evaluate(() => + window.paseoDesktop.invoke("desktop_daemon_status"), + ); + assert( + typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0, + "desktop_daemon_status did not return a serverId", + ); - await captureScreenshot(page, "02-settings-page.png"); - await dismissMobileSidebarIfVisible(page); + const serverId = desktopStatus.serverId.trim(); + await navigateToSettings(page, serverId); - const dragRegionCheck = await inspectTitlebarRegions(page); - const dragScreenshot = await captureScreenshot(page, "03-drag-region.png"); - await collectDragRegionResults(page, dragRegionCheck, dragScreenshot, results); + await captureScreenshot(page, "02-settings-page.png"); + outerSidebarDismissed = await dismissOuterAppSidebarIfVisible(page); - const fullscreenDetails = await inspectFullscreenResizer(page); - const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png"); - results.push({ - check: "fullscreen-resizer", - pass: fullscreenDetails.supported ? fullscreenDetails.passed : true, - details: fullscreenDetails, - screenshot: fullscreenScreenshot, - }); + const dragRegionCheck = await inspectTitlebarRegions(page); + const dragScreenshot = await captureScreenshot(page, "03-drag-region.png"); + await collectDragRegionResults(page, dragRegionCheck, dragScreenshot, results); - await collectDaemonManagementResult(page, serverId, desktopStatus, results); + const fullscreenDetails = await inspectFullscreenWindowChrome(page, desktopDetection.platform); + results.push({ + check: "fullscreen-window-chrome", + pass: fullscreenDetails.supported && fullscreenDetails.passed, + details: fullscreenDetails, + screenshot: fullscreenDetails.screenshot ?? null, + }); - const desktopDetectionScreenshot = await captureScreenshot(page, "06-desktop-detection.png"); - results[0].screenshot = desktopDetectionScreenshot; + await collectSettingsSplitResult(page, serverId, desktopStatus, results); - const report = { - cdpUrl: CDP_URL, - outputDir: OUTPUT_DIR, - pageUrl: page.url(), - desktopStatus, - results, - consoleMessages, - }; + if (outerSidebarDismissed) { + await restoreOuterAppSidebar(page, outerSidebarDismissed); + outerSidebarDismissed = false; + } - const reportPath = path.join(OUTPUT_DIR, "report.json"); - await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + const halfScreenDetails = await inspectHalfScreenSettingsLayout( + page, + desktopDetection.platform, + ); + results.push({ + check: "half-screen-settings-layout", + pass: halfScreenDetails.supported && halfScreenDetails.passed, + details: halfScreenDetails, + screenshot: halfScreenDetails.screenshot ?? null, + }); - const failedChecks = results.filter((result) => !result.pass); - console.log(JSON.stringify(report, null, 2)); - await browser.close(); + const desktopDetectionScreenshot = await captureScreenshot(page, "07-desktop-detection.png"); + results[0].screenshot = desktopDetectionScreenshot; - if (failedChecks.length > 0) { - process.exitCode = 1; + const report = { + cdpUrl: CDP_URL, + outputDir: OUTPUT_DIR, + pageUrl: page.url(), + desktopStatus, + results, + consoleMessages, + }; + + const reportPath = path.join(OUTPUT_DIR, "report.json"); + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + + const failedChecks = results.filter((result) => !result.pass); + console.log(JSON.stringify(report, null, 2)); + if (failedChecks.length > 0) process.exitCode = 1; + } finally { + if (page && !page.isClosed()) { + await clearTitlebarAnnotations(page); + await restoreOuterAppSidebar(page, outerSidebarDismissed); + if (initialPageUrl && page.url() !== initialPageUrl) { + await page.goto(initialPageUrl, { waitUntil: "domcontentloaded" }); + } + } + await browser.close(); } } diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index c5bc67926..805899e94 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({ }, runExternalCliJsonCommand: vi.fn(), runExternalCliTextCommand: vi.fn(), + createNodeEntrypointInvocation: vi.fn(() => ({ + command: "node", + args: [], + env: {}, + })), spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), @@ -59,11 +64,7 @@ vi.mock("../settings/desktop-settings-electron.js", () => ({ })); vi.mock("./runtime-paths.js", () => ({ - createNodeEntrypointInvocation: vi.fn(() => ({ - command: "node", - args: [], - env: {}, - })), + createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint: vi.fn(() => ({ entryPath: "/tmp/daemon.js", execArgv: [], @@ -112,6 +113,8 @@ describe("daemon-manager commands", () => { mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); + mocks.createNodeEntrypointInvocation.mockReset(); + mocks.createNodeEntrypointInvocation.mockReturnValue({ command: "node", args: [], env: {} }); mocks.spawnProcess.mockReset(); mocks.logInfo.mockReset(); mocks.logError.mockReset(); @@ -439,6 +442,9 @@ describe("daemon-manager commands", () => { expect(message).toContain("Daemon failed to start: exit code 1"); expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log"); expect(message).toContain("recent daemon failure"); + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: [] }), + ); expect(mocks.spawnProcess).toHaveBeenCalledWith( "node", [], @@ -450,6 +456,47 @@ describe("daemon-manager commands", () => { ); }); + it("passes stale lock reclaim only after a live desktop daemon is confirmed unresponsive", async () => { + mocks.runExternalCliJsonCommand.mockResolvedValue({ + localDaemon: "unresponsive", + connectedDaemon: "unreachable", + serverId: "", + pid: 7675, + listen: "127.0.0.1:6767", + desktopManaged: true, + }); + mocks.spawnProcess.mockImplementation(() => { + const child = createMockChildProcess(); + scheduleFailedStartup(child); + return child; + }); + + await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( + "Daemon failed to start: exit code 1", + ); + + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: ["--reclaim-stale-pid-lock"] }), + ); + }); + + it("does not pass stale lock reclaim when the status command fails", async () => { + mocks.runExternalCliJsonCommand.mockRejectedValue(new Error("status command failed")); + mocks.spawnProcess.mockImplementation(() => { + const child = createMockChildProcess(); + scheduleFailedStartup(child); + return child; + }); + + await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( + "Daemon failed to start: exit code 1", + ); + + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: [] }), + ); + }); + it("returns the Electron main-process log tail from electron-log", () => { writeFileSync( mocks.appLogPath, diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 331454e45..6393a5bb5 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -385,10 +385,12 @@ async function startDaemon(): Promise { } const daemonRunner = resolveDaemonRunnerEntrypoint(); + const reclaimStalePidLock = + current.status === "errored" && current.desktopManaged && current.error === null; const invocation = createNodeEntrypointInvocation({ entrypoint: daemonRunner, argvMode: "node-script", - args: [], + args: reclaimStalePidLock ? ["--reclaim-stale-pid-lock"] : [], baseEnv: process.env, }); diff --git a/packages/desktop/src/features/browser-profile.test.ts b/packages/desktop/src/features/browser-profile.test.ts new file mode 100644 index 000000000..677708250 --- /dev/null +++ b/packages/desktop/src/features/browser-profile.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "vitest"; +import { + clearPaseoBrowserProfile, + getLegacyPaseoBrowserProfileSession, + getPaseoBrowserProfileSessions, + listPaseoBrowserProfileGuests, + readLegacyPaseoBrowserIds, +} from "./browser-profile.js"; + +class FakeProfileSession { + public readonly storageClears: unknown[] = []; + public cacheClears = 0; + public authClears = 0; + public storageClear: Promise = Promise.resolve(); + + public clearStorageData(options: unknown): Promise { + this.storageClears.push(options); + return this.storageClear; + } + + public clearCache(): Promise { + this.cacheClears += 1; + return Promise.resolve(); + } + + public clearAuthCache(): Promise { + this.authClears += 1; + return Promise.resolve(); + } +} + +class FakeLiveGuest { + public reloads = 0; + + public constructor( + public readonly id: number, + private readonly destroyed = false, + private readonly reloadError: Error | null = null, + ) {} + + public isDestroyed(): boolean { + return this.destroyed; + } + + public reload(): void { + if (this.reloadError) { + throw this.reloadError; + } + this.reloads += 1; + } +} + +class FakeWebContents extends FakeLiveGuest { + public constructor( + id: number, + public readonly session: object, + private readonly type: string, + destroyed = false, + ) { + super(id, destroyed); + } + + public getType(): string { + return this.type; + } +} + +describe("listPaseoBrowserProfileGuests", () => { + test("returns every live webview in the shared profile without deduplicating tabs", () => { + const profileSession = {}; + const firstWindowGuest = new FakeWebContents(1, profileSession, "webview"); + const secondWindowGuest = new FakeWebContents(2, profileSession, "webview"); + const foreignProfileGuest = new FakeWebContents(3, {}, "webview"); + const mainRenderer = new FakeWebContents(4, profileSession, "window"); + const destroyedGuest = new FakeWebContents(5, profileSession, "webview", true); + + const guests = listPaseoBrowserProfileGuests({ + profileSession, + webContents: [ + firstWindowGuest, + secondWindowGuest, + foreignProfileGuest, + mainRenderer, + destroyedGuest, + ], + }); + + expect(guests).toEqual([firstWindowGuest, secondWindowGuest]); + }); +}); + +describe("legacy browser profiles", () => { + test("accepts only unique saved browser ids and resolves their old partitions", () => { + const uuid = "123e4567-e89b-42d3-a456-426614174000"; + const fallbackId = "1700000000000-abcd"; + const browserIds = readLegacyPaseoBrowserIds([uuid, fallbackId, uuid, "not-a-browser-id", 123]); + const partitions: string[] = []; + const sessions = getPaseoBrowserProfileSessions( + { + fromPartition: (partition) => { + partitions.push(partition); + return new FakeProfileSession(); + }, + }, + browserIds, + ); + + expect(partitions).toEqual([ + "persist:paseo-browser", + `persist:paseo-browser-${uuid}`, + `persist:paseo-browser-${fallbackId}`, + ]); + expect(sessions).toHaveLength(3); + }); + + test("resolves one valid legacy profile for tab-close cleanup", () => { + const partitions: string[] = []; + const sessions = { + fromPartition: (partition: string) => { + partitions.push(partition); + return new FakeProfileSession(); + }, + }; + + expect(getLegacyPaseoBrowserProfileSession(sessions, "1700000000000-abcd")).not.toBeNull(); + expect(getLegacyPaseoBrowserProfileSession(sessions, "invalid")).toBeNull(); + expect(partitions).toEqual(["persist:paseo-browser-1700000000000-abcd"]); + }); +}); + +describe("clearPaseoBrowserProfile", () => { + test("clears site data, HTTP cache, and auth before reloading live guests", async () => { + const profile = new FakeProfileSession(); + const legacyProfile = new FakeProfileSession(); + let finishStorageClear: (() => void) | null = null; + profile.storageClear = new Promise((resolve) => { + finishStorageClear = resolve; + }); + const firstGuest = new FakeLiveGuest(1); + const secondGuest = new FakeLiveGuest(2); + + const clearing = clearPaseoBrowserProfile({ + profileSessions: [profile, legacyProfile], + listGuests: () => [firstGuest, secondGuest], + logReloadError: () => {}, + }); + + expect(firstGuest.reloads).toBe(0); + expect(secondGuest.reloads).toBe(0); + finishStorageClear?.(); + await clearing; + + expect(profile.storageClears).toEqual([ + { + storages: [ + "cookies", + "filesystem", + "indexdb", + "localstorage", + "serviceworkers", + "cachestorage", + "websql", + ], + }, + ]); + expect(profile.cacheClears).toBe(1); + expect(profile.authClears).toBe(1); + expect(legacyProfile.storageClears).toEqual(profile.storageClears); + expect(legacyProfile.cacheClears).toBe(1); + expect(legacyProfile.authClears).toBe(1); + expect(firstGuest.reloads).toBe(1); + expect(secondGuest.reloads).toBe(1); + }); + + test("skips destroyed guests and logs individual reload failures", async () => { + const profile = new FakeProfileSession(); + const destroyedGuest = new FakeLiveGuest(1, true); + const reloadError = new Error("guest disappeared"); + const failedGuest = new FakeLiveGuest(2, false, reloadError); + const reloadErrors: Array<{ guestId: number; error: unknown }> = []; + + await clearPaseoBrowserProfile({ + profileSessions: [profile], + listGuests: () => [destroyedGuest, failedGuest], + logReloadError: (guestId, error) => reloadErrors.push({ guestId, error }), + }); + + expect(destroyedGuest.reloads).toBe(0); + expect(failedGuest.reloads).toBe(0); + expect(reloadErrors).toEqual([{ guestId: 2, error: reloadError }]); + }); + + test("propagates clear failures without reloading guests", async () => { + const profile = new FakeProfileSession(); + const clearError = new Error("profile locked"); + profile.storageClear = Promise.reject(clearError); + const guest = new FakeLiveGuest(1); + + await expect( + clearPaseoBrowserProfile({ + profileSessions: [profile], + listGuests: () => [guest], + logReloadError: () => {}, + }), + ).rejects.toBe(clearError); + expect(guest.reloads).toBe(0); + }); +}); diff --git a/packages/desktop/src/features/browser-profile.ts b/packages/desktop/src/features/browser-profile.ts new file mode 100644 index 000000000..1824f48aa --- /dev/null +++ b/packages/desktop/src/features/browser-profile.ts @@ -0,0 +1,123 @@ +export const PASEO_BROWSER_PROFILE_PARTITION = "persist:paseo-browser"; +const LEGACY_BROWSER_ID_PATTERN = + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|\d{13,}-[0-9a-f]+)$/i; +const MAX_LEGACY_BROWSER_PROFILES = 1000; + +const PASEO_BROWSER_STORAGE_TYPES = [ + "cookies", + "filesystem", + "indexdb", + "localstorage", + "serviceworkers", + "cachestorage", + "websql", +] as const; + +interface BrowserProfileSession { + clearStorageData(options: { + storages: Array<(typeof PASEO_BROWSER_STORAGE_TYPES)[number]>; + }): Promise; + clearCache(): Promise; + clearAuthCache(): Promise; +} + +interface BrowserProfileGuest { + readonly id: number; + isDestroyed(): boolean; + reload(): void; +} + +interface BrowserProfileWebContents extends BrowserProfileGuest { + readonly session: object; + getType(): string; +} + +interface ListBrowserProfileGuestsInput { + profileSession: object; + webContents: BrowserProfileWebContents[]; +} + +interface ClearBrowserProfileInput { + profileSessions: BrowserProfileSession[]; + listGuests(): BrowserProfileGuest[]; + logReloadError(guestId: number, error: unknown): void; +} + +interface ElectronSessions { + fromPartition(partition: string): BrowserProfileSession; +} + +export function getPaseoBrowserProfileSession(sessions: ElectronSessions): BrowserProfileSession { + return sessions.fromPartition(PASEO_BROWSER_PROFILE_PARTITION); +} + +export function readLegacyPaseoBrowserIds(input: unknown): string[] { + if (!Array.isArray(input)) { + return []; + } + const browserIds = new Set(); + for (const value of input) { + if (typeof value === "string" && LEGACY_BROWSER_ID_PATTERN.test(value)) { + browserIds.add(value); + if (browserIds.size >= MAX_LEGACY_BROWSER_PROFILES) { + break; + } + } + } + return [...browserIds]; +} + +export function getPaseoBrowserProfileSessions( + sessions: ElectronSessions, + legacyBrowserIds: string[], +): [BrowserProfileSession, ...BrowserProfileSession[]] { + return [ + getPaseoBrowserProfileSession(sessions), + // COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15. + ...legacyBrowserIds.map((browserId) => + sessions.fromPartition(`${PASEO_BROWSER_PROFILE_PARTITION}-${browserId}`), + ), + ]; +} + +export function getLegacyPaseoBrowserProfileSession( + sessions: ElectronSessions, + browserId: string, +): BrowserProfileSession | null { + const [legacyBrowserId] = readLegacyPaseoBrowserIds([browserId]); + return legacyBrowserId + ? sessions.fromPartition(`${PASEO_BROWSER_PROFILE_PARTITION}-${legacyBrowserId}`) + : null; +} + +export function listPaseoBrowserProfileGuests( + input: ListBrowserProfileGuestsInput, +): BrowserProfileGuest[] { + return input.webContents.filter( + (contents) => + !contents.isDestroyed() && + contents.getType() === "webview" && + contents.session === input.profileSession, + ); +} + +export async function clearPaseoBrowserProfile(input: ClearBrowserProfileInput): Promise { + await Promise.all( + input.profileSessions.flatMap((profileSession) => [ + profileSession.clearStorageData({ storages: [...PASEO_BROWSER_STORAGE_TYPES] }), + profileSession.clearCache(), + profileSession.clearAuthCache(), + ]), + ); + + for (const guest of input.listGuests()) { + if (guest.isDestroyed()) { + continue; + } + try { + guest.reload(); + } catch (error) { + input.logReloadError(guest.id, error); + } + } +} diff --git a/packages/desktop/src/features/browser-webviews/index.test.ts b/packages/desktop/src/features/browser-webviews/index.test.ts index a7da6f134..bbeffbe8c 100644 --- a/packages/desktop/src/features/browser-webviews/index.test.ts +++ b/packages/desktop/src/features/browser-webviews/index.test.ts @@ -1,24 +1,33 @@ import { describe, expect, test } from "vitest"; +import { PASEO_BROWSER_PROFILE_PARTITION } from "../browser-profile.js"; import { getPaseoBrowserIdForWebContents, - registerPaseoBrowserWebContents, + getPaseoBrowserWorkspaceId, + isPaseoBrowserWebviewAttach, + preparePaseoBrowserWebContents, + registerAttachedPaseoBrowser, unregisterPaseoBrowser, unregisterPaseoBrowserFromHost, } from "./index.js"; -class FakeRegisteredWebContents { +class FakeRenderer { + public constructor(public readonly id: number) {} + + public isDestroyed(): boolean { + return false; + } +} + +class FakeBrowserGuest { public readonly backgroundThrottlingCalls: boolean[] = []; private destroyedListener: (() => void) | null = null; private destroyed = false; - public constructor(private readonly webContentsId: number) {} - - public get id(): number { - if (this.destroyed) { - throw new TypeError("Object has been destroyed"); - } - return this.webContentsId; - } + public constructor( + public readonly id: number, + public readonly hostWebContents: FakeRenderer, + public readonly session: object, + ) {} public isDestroyed(): boolean { return this.destroyed; @@ -39,66 +48,165 @@ class FakeRegisteredWebContents { } } -class LiveWebContentsIdentity { - public constructor(public readonly id: number) {} - - public isDestroyed(): boolean { - return false; - } -} - -describe("registerPaseoBrowserWebContents", () => { - test("disables guest background throttling once when the webview is registered", () => { - const contents = new FakeRegisteredWebContents(9001); - - registerPaseoBrowserWebContents({ - contents, - browserId: "browser-throttle", - hostWebContentsId: 1001, - }); - - expect(contents.backgroundThrottlingCalls).toEqual([false]); - expect(getPaseoBrowserIdForWebContents(contents)).toBe("browser-throttle"); - - unregisterPaseoBrowser("browser-throttle"); +describe("browser webview attachment", () => { + test("accepts only allowed URLs on the shared profile partition", () => { + expect( + isPaseoBrowserWebviewAttach({ + src: "https://example.com", + partition: PASEO_BROWSER_PROFILE_PARTITION, + }), + ).toBe(true); + expect( + isPaseoBrowserWebviewAttach({ + src: "https://example.com", + partition: "persist:paseo-browser-tab-a", + }), + ).toBe(false); + expect( + isPaseoBrowserWebviewAttach({ src: "https://example.com", partition: "persist:foreign" }), + ).toBe(false); }); - test("unregisters a guest after Electron invalidates its wrapper", () => { - const contents = new FakeRegisteredWebContents(9002); - const liveIdentityWithSameId = new LiveWebContentsIdentity(9002); + test("binds explicit browser identity to the renderer that hosts the guest", () => { + const profileSession = {}; + const renderer = new FakeRenderer(1); + const guest = new FakeBrowserGuest(101, renderer, profileSession); - registerPaseoBrowserWebContents({ - contents, - browserId: "browser-destroyed", - hostWebContentsId: 1001, + const registered = registerAttachedPaseoBrowser({ + browserId: "browser-a", + workspaceId: "workspace-a", + webContentsId: guest.id, + sender: renderer, + profileSession, + findWebContents: () => guest, }); - expect(() => contents.destroy()).not.toThrow(); - - expect(getPaseoBrowserIdForWebContents(liveIdentityWithSameId)).toBeNull(); + expect(registered).toBe(true); + expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-a"); + expect(getPaseoBrowserWorkspaceId("browser-a")).toBe("workspace-a"); + unregisterPaseoBrowser("browser-a"); }); - test("unregisters a browser only from its requesting host", () => { - const firstContents = new FakeRegisteredWebContents(9003); - const secondContents = new FakeRegisteredWebContents(9004); - registerPaseoBrowserWebContents({ - contents: firstContents, - browserId: "browser-shared-hosts", - hostWebContentsId: 1001, - }); - registerPaseoBrowserWebContents({ - contents: secondContents, - browserId: "browser-shared-hosts", - hostWebContentsId: 1002, + test("rejects a guest hosted by another renderer", () => { + const profileSession = {}; + const owner = new FakeRenderer(1); + const claimant = new FakeRenderer(2); + const guest = new FakeBrowserGuest(201, owner, profileSession); + + const registered = registerAttachedPaseoBrowser({ + browserId: "browser-rejected-owner", + workspaceId: "workspace-a", + webContentsId: guest.id, + sender: claimant, + profileSession, + findWebContents: () => guest, }); - unregisterPaseoBrowserFromHost(1001, "browser-shared-hosts"); + expect(registered).toBe(false); + expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); + }); - expect(getPaseoBrowserIdForWebContents(new LiveWebContentsIdentity(9003))).toBeNull(); - expect(getPaseoBrowserIdForWebContents(new LiveWebContentsIdentity(9004))).toBe( - "browser-shared-hosts", - ); + test("rejects a guest outside the shared profile", () => { + const profileSession = {}; + const renderer = new FakeRenderer(1); + const guest = new FakeBrowserGuest(301, renderer, {}); + const registered = registerAttachedPaseoBrowser({ + browserId: "browser-rejected-profile", + workspaceId: "workspace-a", + webContentsId: guest.id, + sender: renderer, + profileSession, + findWebContents: () => guest, + }); + + expect(registered).toBe(false); + expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); + }); + + test("concurrent windows cannot swap browser identities", () => { + const profileSession = {}; + const firstRenderer = new FakeRenderer(1); + const secondRenderer = new FakeRenderer(2); + const firstGuest = new FakeBrowserGuest(401, firstRenderer, profileSession); + const secondGuest = new FakeBrowserGuest(402, secondRenderer, profileSession); + const guests = new Map([ + [firstGuest.id, firstGuest], + [secondGuest.id, secondGuest], + ]); + + registerAttachedPaseoBrowser({ + browserId: "browser-second", + workspaceId: "workspace-second", + webContentsId: secondGuest.id, + sender: secondRenderer, + profileSession, + findWebContents: (id) => guests.get(id) ?? null, + }); + registerAttachedPaseoBrowser({ + browserId: "browser-first", + workspaceId: "workspace-first", + webContentsId: firstGuest.id, + sender: firstRenderer, + profileSession, + findWebContents: (id) => guests.get(id) ?? null, + }); + + expect(getPaseoBrowserIdForWebContents(firstGuest)).toBe("browser-first"); + expect(getPaseoBrowserIdForWebContents(secondGuest)).toBe("browser-second"); + unregisterPaseoBrowser("browser-first"); + unregisterPaseoBrowser("browser-second"); + }); + + test("unregisters the same browser only from its requesting host", () => { + const profileSession = {}; + const firstRenderer = new FakeRenderer(11); + const secondRenderer = new FakeRenderer(22); + const firstGuest = new FakeBrowserGuest(501, firstRenderer, profileSession); + const secondGuest = new FakeBrowserGuest(502, secondRenderer, profileSession); + + for (const [renderer, guest] of [ + [firstRenderer, firstGuest], + [secondRenderer, secondGuest], + ] as const) { + registerAttachedPaseoBrowser({ + browserId: "browser-shared-hosts", + workspaceId: "workspace-shared", + webContentsId: guest.id, + sender: renderer, + profileSession, + findWebContents: () => guest, + }); + } + + unregisterPaseoBrowserFromHost(firstRenderer.id, "browser-shared-hosts"); + + expect(getPaseoBrowserIdForWebContents(firstGuest)).toBeNull(); + expect(getPaseoBrowserIdForWebContents(secondGuest)).toBe("browser-shared-hosts"); + expect(getPaseoBrowserWorkspaceId("browser-shared-hosts")).toBe("workspace-shared"); unregisterPaseoBrowser("browser-shared-hosts"); }); + + test("prepares throttling once and removes registration when the guest is destroyed", () => { + const profileSession = {}; + const renderer = new FakeRenderer(31); + const guest = new FakeBrowserGuest(601, renderer, profileSession); + preparePaseoBrowserWebContents(guest); + registerAttachedPaseoBrowser({ + browserId: "browser-cleanup", + workspaceId: "workspace-cleanup", + webContentsId: guest.id, + sender: renderer, + profileSession, + findWebContents: () => guest, + }); + + expect(guest.backgroundThrottlingCalls).toEqual([false]); + expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-cleanup"); + + guest.destroy(); + + expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); + expect(guest.backgroundThrottlingCalls).toEqual([false]); + }); }); diff --git a/packages/desktop/src/features/browser-webviews/index.ts b/packages/desktop/src/features/browser-webviews/index.ts index c20f7474a..f16e658b8 100644 --- a/packages/desktop/src/features/browser-webviews/index.ts +++ b/packages/desktop/src/features/browser-webviews/index.ts @@ -1,12 +1,18 @@ import { webContents as allWebContents, type WebContents } from "electron"; +import { PASEO_BROWSER_PROFILE_PARTITION } from "../browser-profile.js"; import { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest, isAllowedBrowserWebviewUrl, + PendingBrowserWindowOpenRequests, } from "./window-open.js"; -import { PaseoBrowserWebviewRegistry, type BrowserWorkspaceRegistration } from "./registry.js"; +import { PaseoBrowserWebviewRegistry } from "./registry.js"; -export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest }; +export { + BROWSER_NEW_TAB_REQUEST_EVENT, + handleBrowserWindowOpenRequest, + PendingBrowserWindowOpenRequests, +}; const browserRegistry = new PaseoBrowserWebviewRegistry(); @@ -16,33 +22,28 @@ interface BrowserWebContentsIdentity { } interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity { + readonly hostWebContents: BrowserWebContentsIdentity | null; + readonly session: object; setBackgroundThrottling(allowed: boolean): void; once(event: "destroyed", listener: () => void): void; } -interface RegisterBrowserWebContentsInput { +interface AttachedBrowserRegistration { browserId: string; - contents: RegisteredBrowserWebContents; - hostWebContentsId: number; + workspaceId: string; + webContentsId: number; } -function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null { - const prefix = "persist:paseo-browser-"; - if (!partition?.startsWith(prefix)) { - return null; - } - const browserId = partition.slice(prefix.length).trim(); - return browserId.length > 0 ? browserId : null; +interface RegisterAttachedBrowserInput extends AttachedBrowserRegistration { + sender: BrowserWebContentsIdentity; + profileSession: object; + findWebContents(webContentsId: number): RegisteredBrowserWebContents | null; } -export function readBrowserIdFromWebviewAttach(input: { - src?: string; - partition?: string; -}): string | null { - if (!isAllowedBrowserWebviewUrl(input.src)) { - return null; - } - return getBrowserIdFromWebviewPartition(input.partition); +export function isPaseoBrowserWebviewAttach(input: { src?: string; partition?: string }): boolean { + return ( + isAllowedBrowserWebviewUrl(input.src) && input.partition === PASEO_BROWSER_PROFILE_PARTITION + ); } export function listRegisteredPaseoBrowserIds(): string[] { @@ -53,23 +54,37 @@ export function getPaseoBrowserWebviewRegistry(): PaseoBrowserWebviewRegistry { return browserRegistry; } -export function registerPaseoBrowserWebContents({ - contents, - browserId, - hostWebContentsId, -}: RegisterBrowserWebContentsInput): void { +export function preparePaseoBrowserWebContents(contents: RegisteredBrowserWebContents): void { const webContentsId = contents.id; contents.setBackgroundThrottling(false); - browserRegistry.registerWebContents({ - webContentsId, - browserId, - hostWebContentsId, - }); contents.once("destroyed", () => { browserRegistry.unregisterWebContents(webContentsId); }); } +export function registerAttachedPaseoBrowser(input: RegisterAttachedBrowserInput): boolean { + const guest = input.findWebContents(input.webContentsId); + if ( + !guest || + guest.isDestroyed() || + guest.hostWebContents !== input.sender || + guest.session !== input.profileSession + ) { + return false; + } + + browserRegistry.registerWebContents({ + webContentsId: input.webContentsId, + browserId: input.browserId, + hostWebContentsId: input.sender.id, + }); + browserRegistry.registerWorkspace({ + browserId: input.browserId, + workspaceId: input.workspaceId, + }); + return true; +} + export function getPaseoBrowserIdForWebContents( contents: BrowserWebContentsIdentity | null, ): string | null { @@ -79,10 +94,6 @@ export function getPaseoBrowserIdForWebContents( return browserRegistry.getBrowserIdForWebContents(contents.id); } -export function registerPaseoBrowserWorkspace(input: BrowserWorkspaceRegistration): void { - browserRegistry.registerWorkspace(input); -} - export function unregisterPaseoBrowser(browserId: string): void { browserRegistry.unregisterBrowser(browserId); } diff --git a/packages/desktop/src/features/browser-webviews/window-open.test.ts b/packages/desktop/src/features/browser-webviews/window-open.test.ts index 9417c4e68..e40d490ad 100644 --- a/packages/desktop/src/features/browser-webviews/window-open.test.ts +++ b/packages/desktop/src/features/browser-webviews/window-open.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { handleBrowserWindowOpenRequest } from "."; +import { handleBrowserWindowOpenRequest, PendingBrowserWindowOpenRequests } from "."; describe("browser webview window-open requests", () => { it("denies Electron window creation and requests a Paseo browser tab", () => { @@ -32,3 +32,23 @@ describe("browser webview window-open requests", () => { expect(requestNewTab).not.toHaveBeenCalled(); }); }); + +describe("pending browser window-open requests", () => { + it("holds early allowed popups until browser identity registration", () => { + const pending = new PendingBrowserWindowOpenRequests(); + pending.add(101, "https://example.com/first"); + pending.add(101, "file:///etc/passwd"); + pending.add(101, "https://example.com/second"); + + expect(pending.take(101)).toEqual(["https://example.com/first", "https://example.com/second"]); + expect(pending.take(101)).toEqual([]); + }); + + it("drops pending popups when an unregistered guest is destroyed", () => { + const pending = new PendingBrowserWindowOpenRequests(); + pending.add(202, "https://example.com/target"); + pending.delete(202); + + expect(pending.take(202)).toEqual([]); + }); +}); diff --git a/packages/desktop/src/features/browser-webviews/window-open.ts b/packages/desktop/src/features/browser-webviews/window-open.ts index 18535722a..f535abb1b 100644 --- a/packages/desktop/src/features/browser-webviews/window-open.ts +++ b/packages/desktop/src/features/browser-webviews/window-open.ts @@ -5,6 +5,34 @@ export interface BrowserNewTabRequestPayload { url: string; } +const MAX_PENDING_WINDOW_OPEN_REQUESTS_PER_GUEST = 20; + +export class PendingBrowserWindowOpenRequests { + private readonly urlsByWebContentsId = new Map(); + + public add(webContentsId: number, url: string): void { + if (!isAllowedBrowserWebviewUrl(url)) { + return; + } + const urls = this.urlsByWebContentsId.get(webContentsId) ?? []; + if (urls.length >= MAX_PENDING_WINDOW_OPEN_REQUESTS_PER_GUEST) { + return; + } + urls.push(url); + this.urlsByWebContentsId.set(webContentsId, urls); + } + + public take(webContentsId: number): string[] { + const urls = this.urlsByWebContentsId.get(webContentsId) ?? []; + this.urlsByWebContentsId.delete(webContentsId); + return urls; + } + + public delete(webContentsId: number): void { + this.urlsByWebContentsId.delete(webContentsId); + } +} + export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean { if (!value) { return true; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index e27d4bb05..78774a4b3 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -21,6 +21,7 @@ import { protocol, screen, session, + webContents, } from "electron"; import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js"; import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js"; @@ -53,14 +54,23 @@ import { getPaseoBrowserWebviewRegistry, handleBrowserWindowOpenRequest, listRegisteredPaseoBrowserIds, - readBrowserIdFromWebviewAttach, + isPaseoBrowserWebviewAttach, + preparePaseoBrowserWebContents, + PendingBrowserWindowOpenRequests, registerBrowserWebviewNavigationGuards, unregisterPaseoBrowserFromHost, - registerPaseoBrowserWorkspace, - registerPaseoBrowserWebContents, + registerAttachedPaseoBrowser, setWorkspaceActivePaseoBrowserId, unregisterPaseoBrowserHost, } from "./features/browser-webviews/index.js"; +import { + clearPaseoBrowserProfile, + getLegacyPaseoBrowserProfileSession, + getPaseoBrowserProfileSession, + getPaseoBrowserProfileSessions, + listPaseoBrowserProfileGuests, + readLegacyPaseoBrowserIds, +} from "./features/browser-profile.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; import { PendingOpenProjectStore } from "./pending-open-project-store.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; @@ -83,14 +93,19 @@ const APP_SCHEME = "paseo"; const PASEO_DEBUG = process.env.PASEO_DEBUG === "1"; const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1"; const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo"; +const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests(); const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE"; const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop"; app.setName(APP_NAME); -function readBrowserWorkspaceInput( - input: unknown, -): { browserId: string; workspaceId: string } | null { +interface AttachedBrowserInput { + browserId: string; + workspaceId: string; + webContentsId: number; +} + +function readAttachedBrowserInput(input: unknown): AttachedBrowserInput | null { if (typeof input !== "object" || input === null || Array.isArray(input)) { return null; } @@ -101,7 +116,18 @@ function readBrowserWorkspaceInput( if (typeof record.workspaceId !== "string" || record.workspaceId.trim().length === 0) { return null; } - return { browserId: record.browserId.trim(), workspaceId: record.workspaceId.trim() }; + if ( + typeof record.webContentsId !== "number" || + !Number.isInteger(record.webContentsId) || + record.webContentsId <= 0 + ) { + return null; + } + return { + browserId: record.browserId.trim(), + workspaceId: record.workspaceId.trim(), + webContentsId: record.webContentsId, + }; } function readActiveBrowserInput( @@ -118,7 +144,6 @@ function readActiveBrowserInput( return { workspaceId: record.workspaceId.trim(), browserId: browserId || null }; } -const pendingBrowserWebviewIdsByHostWebContentsId = new Map(); const browserKeyboard = new BrowserKeyboard(getPaseoBrowserWebviewRegistry()); browserKeyboard.registerIpc(); @@ -274,16 +299,64 @@ function normalizeBrowserCaptureRect( }; } -ipcMain.handle("paseo:browser:register-workspace-browser", (_event, rawInput: unknown) => { - const input = readBrowserWorkspaceInput(rawInput); - if (input) { - registerPaseoBrowserWorkspace(input); +ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) => { + const input = readAttachedBrowserInput(rawInput); + if (!input) { + throw new Error("Invalid attached browser registration"); + } + const registered = registerAttachedPaseoBrowser({ + ...input, + sender: event.sender, + profileSession: getPaseoBrowserProfileSession(session), + findWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null, + }); + if (!registered) { + throw new Error("Attached browser registration was rejected"); + } + const guest = webContents.fromId(input.webContentsId); + if (!guest) { + throw new Error("Attached browser guest disappeared after registration"); + } + browserKeyboard.attach({ contents: guest, hostContents: event.sender }); + log.info("[browser-webview] registered", { + browserId: input.browserId, + webContentsId: input.webContentsId, + registeredBrowserIds: listRegisteredPaseoBrowserIds(), + }); + for (const url of pendingBrowserWindowOpenRequests.take(input.webContentsId)) { + event.sender.send(BROWSER_NEW_TAB_REQUEST_EVENT, { + sourceBrowserId: input.browserId, + url, + }); } }); -ipcMain.handle("paseo:browser:unregister-workspace-browser", (event, browserId: unknown) => { +ipcMain.handle("paseo:browser:unregister-workspace-browser", async (event, browserId: unknown) => { if (typeof browserId === "string" && browserId.trim().length > 0) { - unregisterPaseoBrowserFromHost(event.sender.id, browserId.trim()); + const normalizedBrowserId = browserId.trim(); + const hasOtherHost = getPaseoBrowserWebviewRegistry().hasBrowserInOtherHostWindow( + event.sender.id, + normalizedBrowserId, + ); + unregisterPaseoBrowserFromHost(event.sender.id, normalizedBrowserId); + // COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15. + const legacyProfile = hasOtherHost + ? null + : getLegacyPaseoBrowserProfileSession(session, normalizedBrowserId); + if (legacyProfile) { + try { + await clearPaseoBrowserProfile({ + profileSessions: [legacyProfile], + listGuests: () => [], + logReloadError: () => {}, + }); + } catch (error) { + log.warn("[browser-profile] failed to clear legacy tab profile", { + browserId: normalizedBrowserId, + error, + }); + } + } } }); @@ -335,21 +408,23 @@ ipcMain.handle("paseo:browser:open-devtools", (event, browserId: unknown) => { return result; }); -ipcMain.handle("paseo:browser:clear-partition", async (event, browserId: unknown) => { - if (typeof browserId !== "string" || browserId.trim().length === 0) { - return; - } - const normalizedBrowserId = browserId.trim(); - if ( - getPaseoBrowserWebviewRegistry().hasBrowserInOtherHostWindow( - event.sender.id, - normalizedBrowserId, - ) - ) { - return; - } - const partition = `persist:paseo-browser-${normalizedBrowserId}`; - await session.fromPartition(partition).clearStorageData(); +ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds: unknown) => { + const profileSessions = getPaseoBrowserProfileSessions( + session, + readLegacyPaseoBrowserIds(rawLegacyBrowserIds), + ); + const profileSession = profileSessions[0]; + await clearPaseoBrowserProfile({ + profileSessions, + listGuests: () => + listPaseoBrowserProfileGuests({ + profileSession, + webContents: webContents.getAllWebContents(), + }), + logReloadError: (webContentsId, error) => { + log.warn("[browser-profile] failed to reload guest", { webContentsId, error }); + }, + }); }); ipcMain.handle( @@ -546,7 +621,6 @@ async function createWindow( pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath); mainWindow.on("closed", () => { pendingOpenProjectStore.delete(webContentsId); - pendingBrowserWebviewIdsByHostWebContentsId.delete(webContentsId); unregisterPaseoBrowserHost(webContentsId); browserKeyboard.detachHost(webContentsId); }); @@ -567,19 +641,10 @@ async function createWindow( setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => { - const browserId = readBrowserIdFromWebviewAttach(params); - if (!browserId) { + if (!isPaseoBrowserWebviewAttach(params)) { event.preventDefault(); return; } - const pendingBrowserWebviewIds = pendingBrowserWebviewIdsByHostWebContentsId.get( - mainWindow.webContents.id, - ); - if (pendingBrowserWebviewIds) { - pendingBrowserWebviewIds.push(browserId); - } else { - pendingBrowserWebviewIdsByHostWebContentsId.set(mainWindow.webContents.id, [browserId]); - } webPreferences.nodeIntegration = false; // The sandboxed keyboard preload must run in every frame so focused iframes keep // the same page-first shortcut boundary. Node integration remains disabled. @@ -597,35 +662,23 @@ async function createWindow( webPreferences.preload = getBrowserKeyboardPreloadPath(); }); mainWindow.webContents.on("did-attach-webview", (_event, contents) => { - const pendingBrowserWebviewIds = pendingBrowserWebviewIdsByHostWebContentsId.get( - mainWindow.webContents.id, - ); - const browserId = pendingBrowserWebviewIds?.shift() ?? null; - if (browserId) { - registerPaseoBrowserWebContents({ - contents, - browserId, - hostWebContentsId: mainWindow.webContents.id, - }); - browserKeyboard.attach({ - contents, - hostContents: mainWindow.webContents, - }); - log.info("[browser-webview] registered", { - browserId, - webContentsId: contents.id, - registeredBrowserIds: listRegisteredPaseoBrowserIds(), - }); - } - contents.setWindowOpenHandler(({ url }) => - handleBrowserWindowOpenRequest({ + preparePaseoBrowserWebContents(contents); + contents.once("destroyed", () => { + pendingBrowserWindowOpenRequests.delete(contents.id); + }); + contents.setWindowOpenHandler(({ url }) => { + const sourceBrowserId = getPaseoBrowserIdForWebContents(contents); + if (!sourceBrowserId) { + pendingBrowserWindowOpenRequests.add(contents.id, url); + } + return handleBrowserWindowOpenRequest({ url, - sourceBrowserId: getPaseoBrowserIdForWebContents(contents), + sourceBrowserId, requestNewTab: (payload) => { mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload); }, - }), - ); + }); + }); contents.on("context-menu", (_contextMenuEvent, params) => { showBrowserWebviewContextMenu(mainWindow, contents, params); }); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index fa9a71318..13c49d57d 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,8 +1,15 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { BrowserKeyboardPolicy } from "./features/browser-keyboard/index.js"; +import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js"; type EventHandler = (payload: unknown) => void; +interface AttachedBrowserRegistration { + browserId: string; + workspaceId: string; + webContentsId: number; +} + contextBridge.exposeInMainWorld("paseoDesktop", { platform: process.platform, invoke: (command: string, args?: Record) => @@ -25,6 +32,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:window:openNew", options), getCurrentWindow: () => ({ toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"), + setFullscreen: (fullscreen: boolean) => + ipcRenderer.invoke("paseo:window:setFullscreen", fullscreen), isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"), updateWindowControls: (update: { height?: number; @@ -79,16 +88,17 @@ contextBridge.exposeInMainWorld("paseoDesktop", { browser: { setShortcutPolicy: (input: BrowserKeyboardPolicy) => ipcRenderer.invoke("paseo:browser:set-shortcut-policy", input), - registerWorkspaceBrowser: (input: { browserId: string; workspaceId: string }) => - ipcRenderer.invoke("paseo:browser:register-workspace-browser", input), + profilePartition: PASEO_BROWSER_PROFILE_PARTITION, + registerAttachedBrowser: (input: AttachedBrowserRegistration) => + ipcRenderer.invoke("paseo:browser:register-attached", input), unregisterWorkspaceBrowser: (browserId: string) => ipcRenderer.invoke("paseo:browser:unregister-workspace-browser", browserId), setWorkspaceActiveBrowser: (input: { workspaceId: string; browserId: string | null }) => ipcRenderer.invoke("paseo:browser:set-workspace-active-browser", input), openDevTools: (browserId: string) => ipcRenderer.invoke("paseo:browser:open-devtools", browserId), - clearPartition: (browserId: string) => - ipcRenderer.invoke("paseo:browser:clear-partition", browserId), + clearProfile: (legacyBrowserIds: string[]) => + ipcRenderer.invoke("paseo:browser:clear-profile", legacyBrowserIds), executeAutomationCommand: (request: Record) => ipcRenderer.invoke("paseo:browser:execute-automation-command", request), captureElement: ( diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index a56bb51c1..19aaab22a 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -194,6 +194,11 @@ export function registerWindowManager(): void { return win?.isFullScreen() ?? false; }); + ipcMain.handle("paseo:window:setFullscreen", (event, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + BrowserWindow.fromWebContents(event.sender)?.setFullScreen(fullscreen); + }); + ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: unknown) => { if (process.platform === "darwin" || process.platform === "linux") { const badgeCount = readBadgeCount(count); diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 57f52d240..2a037d913 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -1,12 +1,55 @@ import { describe, expect, it } from "vitest"; import { + AgentForkContextRequestMessageSchema, + AgentForkContextResponseMessageSchema, CreateAgentRequestMessageSchema, CreatePaseoWorktreeRequestSchema, SendAgentMessageRequestSchema, } from "./messages.js"; describe("shared messages attachments", () => { + it("preserves an optional timeline cursor on fork-context messages", () => { + const boundaryCursor = { epoch: "timeline-1", seq: 42 }; + const request = AgentForkContextRequestMessageSchema.parse({ + type: "agent.fork_context.request", + agentId: "agent-1", + requestId: "fork-1", + boundaryCursor, + }); + const response = AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: null, + boundaryCursor, + error: null, + }, + }); + + expect(request.boundaryCursor).toEqual(boundaryCursor); + expect(response.payload.boundaryCursor).toEqual(boundaryCursor); + }); + + it("accepts a legacy fork-context response without a timeline cursor", () => { + expect( + AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: "assistant-1", + error: null, + }, + }).payload.boundaryCursor, + ).toBeUndefined(); + }); + it("keeps valid review attachments", () => { const parsed = SendAgentMessageRequestSchema.parse({ type: "send_agent_message_request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ec05be176..965b95aec 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1321,6 +1321,7 @@ export const ProviderSubagentTimelineRequestMessageSchema = z.object({ export const AgentForkContextRequestMessageSchema = z.object({ type: z.literal("agent.fork_context.request"), agentId: z.string(), + boundaryCursor: AgentTimelineCursorSchema.optional(), boundaryMessageId: z.string().optional(), requestId: z.string(), }); @@ -2429,6 +2430,8 @@ export const ServerInfoStatusPayloadSchema = z daemonSelfUpdate: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. @@ -3129,6 +3132,7 @@ export const AgentForkContextResponseMessageSchema = z.object({ attachment: TextAttachmentSchema.nullable(), itemCount: z.number().int().nonnegative(), boundaryMessageId: z.string().nullable(), + boundaryCursor: AgentTimelineCursorSchema.nullable().optional(), error: z.string().nullable(), }), }); @@ -3139,6 +3143,7 @@ export const CancelAgentResponseMessageSchema = z.object({ requestId: z.string(), agentId: z.string(), agent: AgentSnapshotPayloadSchema.nullable(), + error: z.string().nullable().optional(), }), }); diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index d7d23d41b..b379e6179 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -5,6 +5,7 @@ import { acquirePidLock, PidLockError, releasePidLock, + startPidLockHeartbeat, updatePidLock, } from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; @@ -17,11 +18,13 @@ process.title = "Paseo Supervisor"; interface DaemonRunnerConfig { devMode: boolean; + reclaimStalePidLock: boolean; workerArgs: string[]; } function parseConfig(argv: string[]): DaemonRunnerConfig { let devMode = false; + let reclaimStalePidLock = false; const workerArgs: string[] = []; for (const arg of argv) { @@ -29,10 +32,14 @@ function parseConfig(argv: string[]): DaemonRunnerConfig { devMode = true; continue; } + if (arg === "--reclaim-stale-pid-lock") { + reclaimStalePidLock = true; + continue; + } workerArgs.push(arg); } - return { devMode, workerArgs }; + return { devMode, reclaimStalePidLock, workerArgs }; } function resolveWorkerEntry(): string { @@ -109,6 +116,7 @@ async function main(): Promise { try { await acquirePidLock(paseoHome, null, { ownerPid: process.pid, + reclaimStaleDesktopLock: config.reclaimStalePidLock, }); } catch (error) { if (error instanceof PidLockError) { @@ -120,17 +128,29 @@ async function main(): Promise { } let lockReleased = false; + let requestSupervisorShutdown: ((reason: string) => void) | null = null; + const stopLockHeartbeat = startPidLockHeartbeat(paseoHome, { + ownerPid: process.pid, + onError: (error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`PID lock heartbeat failed: ${message}\n`); + if (error instanceof PidLockError) { + requestSupervisorShutdown?.("pid_lock_ownership_lost"); + } + }, + }); const releaseLock = async (): Promise => { if (lockReleased) { return; } lockReleased = true; + stopLockHeartbeat(); await releasePidLock(paseoHome, { ownerPid: process.pid, }); }; - runSupervisor({ + const supervisor = runSupervisor({ name: "DaemonRunner", startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)", resolveWorkerEntry: () => workerEntry, @@ -159,6 +179,7 @@ async function main(): Promise { }, onSupervisorExit: releaseLock, }); + requestSupervisorShutdown = supervisor.requestShutdown; } void main().catch((error) => { diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 6e7c1b032..4e56c9612 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -47,6 +47,10 @@ interface SupervisorOptions { logFile?: SupervisorLogFileOptions; } +export interface SupervisorController { + requestShutdown(reason: string): void; +} + function describeExit(code: number | null, signal: NodeJS.Signals | null): string { return signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); } @@ -105,7 +109,7 @@ function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined }); } -export function runSupervisor(options: SupervisorOptions): void { +export function runSupervisor(options: SupervisorOptions): SupervisorController { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); const workerEnv = options.workerEnv ?? process.env; @@ -331,4 +335,6 @@ export function runSupervisor(options: SupervisorOptions): void { process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage); spawnWorker(); + + return { requestShutdown }; } diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 75ae75fe7..2f76fe635 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -315,6 +315,8 @@ second line'`, contextKind: "chat_history", title: "Chat history", }); + expect(result.attachment.text).toMatch(/^\n/); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); expect(result.attachment.text).toContain("Source agent: Source Agent"); expect(result.attachment.text).toContain("Source directory: /repo"); expect(result.attachment.text).toContain("[User] Ship the thing"); @@ -394,6 +396,41 @@ second line'`, expect(result.attachment.text).not.toContain("after boundary"); }); + it("selects a synthetic assistant error by its timeline cursor", () => { + const result = buildAgentForkContextAttachment({ + cursorBoundary: { + timelineEpoch: "timeline-1", + cursor: { epoch: "timeline-1", seq: 2 }, + }, + rows: [ + row(1, { type: "user_message", text: "Try the task", messageId: "user-1" }), + row(2, { type: "assistant_message", text: "[System Error] provider failed" }), + row(3, { + type: "assistant_message", + text: "This belongs to a later turn.", + messageId: "assistant-2", + }), + ], + }); + + expect(result.boundaryCursor).toEqual({ epoch: "timeline-1", seq: 2 }); + expect(result.boundaryMessageId).toBeNull(); + expect(result.attachment.text).toContain("[System Error] provider failed"); + expect(result.attachment.text).not.toContain("This belongs to a later turn."); + }); + + it("rejects a cursor from a previous timeline epoch", () => { + expect(() => + buildAgentForkContextAttachment({ + cursorBoundary: { + timelineEpoch: "timeline-2", + cursor: { epoch: "timeline-1", seq: 2 }, + }, + rows: [row(2, { type: "assistant_message", text: "Stale result." })], + }), + ).toThrow("Selected timeline position is no longer available."); + }); + it("rejects missing assistant boundaries instead of silently using the wrong context", () => { expect(() => buildAgentForkContextAttachment({ diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 359fb4280..da3318c1a 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -216,30 +216,55 @@ export function curateAgentActivity( : "No activity to display."; } +interface ForkCursorBoundary { + timelineEpoch: string; + cursor: { epoch: string; seq: number }; +} + function selectForkContextRows(input: { rows: readonly AgentTimelineRow[]; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; -}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } { +}): { + items: AgentTimelineItem[]; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { + const boundaryCursor = input.cursorBoundary?.cursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; - if (!boundaryMessageId) { + if (!boundaryCursor && !boundaryMessageId) { const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); return { items: projected.map((entry) => entry.item), + boundaryCursor: null, boundaryMessageId: null, }; } - const boundaryIndex = input.rows.findLastIndex( - (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, - ); + if ( + input.cursorBoundary && + input.cursorBoundary.cursor.epoch !== input.cursorBoundary.timelineEpoch + ) { + throw new Error("Selected timeline position is no longer available."); + } + const boundaryIndex = boundaryCursor + ? input.rows.findIndex((row) => row.seq === boundaryCursor.seq) + : input.rows.findLastIndex( + (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, + ); if (boundaryIndex < 0) { - throw new Error("Selected assistant message is no longer available."); + throw new Error( + boundaryCursor + ? "Selected timeline position is no longer available." + : "Selected assistant message is no longer available.", + ); } const selectedRows = input.rows.slice(0, boundaryIndex + 1); const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" }); return { items: projected.map((entry) => entry.item), + boundaryCursor, boundaryMessageId, }; } @@ -263,17 +288,24 @@ function buildForkContextText(input: { if (cwd) { header.push(`Source directory: ${cwd}`); } - return `${header.join("\n")}\n\n${input.body}`; + return `\n${header.join("\n")}\n\n${input.body}\n`; } export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; agentTitle?: string | null; cwd?: string | null; -}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } { +}): { + attachment: TextAgentAttachment; + itemCount: number; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { const selected = selectForkContextRows({ rows: input.rows, + cursorBoundary: input.cursorBoundary, boundaryMessageId: input.boundaryMessageId, }); const entries = curateProjectedActivityEntries(selected.items, { @@ -299,6 +331,7 @@ export function buildAgentForkContextAttachment(input: { }), }, itemCount: selected.items.length, + boundaryCursor: selected.boundaryCursor, boundaryMessageId: selected.boundaryMessageId, }; } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5cbff4b03..759f9b2f4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -54,6 +54,28 @@ function deferred(): Deferred { return { promise, resolve, reject }; } +function waitForAgentLifecycle( + manager: AgentManager, + agentId: string, + lifecycle: ManagedAgent["lifecycle"], +): Promise { + return new Promise((resolve) => { + const unsubscribe = manager.subscribe( + (event) => { + if ( + event.type === "agent_state" && + event.agent.id === agentId && + event.agent.lifecycle === lifecycle + ) { + unsubscribe(); + resolve(); + } + }, + { agentId, replayState: false }, + ); + }); +} + const TEST_CAPABILITIES = { supportsStreaming: false, supportsSessionPersistence: false, @@ -402,6 +424,83 @@ class TestAgentSession implements AgentSession { async close(): Promise {} } +class ControlledInterruptSession extends TestAgentSession { + interruptCalled = false; + + constructor( + config: AgentSessionConfig, + readonly turnId: string, + private readonly interruptBehavior: (session: ControlledInterruptSession) => Promise, + ) { + super(config); + } + + override async startTurn(): Promise<{ turnId: string }> { + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId: this.turnId }); + }, 0); + return { turnId: this.turnId }; + } + + override async interrupt(): Promise { + this.interruptCalled = true; + await this.interruptBehavior(this); + } +} + +interface ControlledInterruptFixture { + agentId: string; + manager: AgentManager; + session: ControlledInterruptSession; + startForegroundRun(): Promise; + cleanup(): void; +} + +async function createControlledInterruptFixture(options: { + name: string; + agentId: string; + turnId: string; + interrupt: (session: ControlledInterruptSession) => Promise; +}): Promise { + const workdir = mkdtempSync(join(tmpdir(), `agent-manager-${options.name}-`)); + const session = new ControlledInterruptSession( + { provider: "codex", cwd: workdir }, + options.turnId, + options.interrupt, + ); + const client = new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: new AgentStorage(join(workdir, "agents"), logger), + logger, + rescueTimeouts: { interruptSessionMs: 10 }, + idFactory: () => options.agentId, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + return { + agentId: agent.id, + manager, + session, + async startForegroundRun() { + const run = manager.streamAgent(agent.id, "exercise cancellation"); + void (async () => { + for await (const _event of run) { + // Keep the foreground stream subscribed until the controlled turn settles. + } + })(); + await manager.waitForAgentRunStart(agent.id); + }, + cleanup: () => rmSync(workdir, { recursive: true, force: true }), + }; +} + class HeldRuntimeInfoSession extends TestAgentSession { private readonly runtimeInfoRequested = deferred(); private readonly runtimeInfoAllowed = deferred(); @@ -1357,77 +1456,124 @@ test("reloadAgentSession completes when the previous session close hangs", async } }); -test("cancelAgentRun completes when provider interrupt hangs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-interrupt-timeout-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HangingInterruptSession extends TestAgentSession { - interruptCalled = false; - - override async interrupt(): Promise { - this.interruptCalled = true; - await new Promise(() => {}); - } - } - - class HangingInterruptClient extends TestAgentClient { - readonly session = new HangingInterruptSession({ - provider: "codex", - cwd: workdir, - }); - - override async createSession(): Promise { - return this.session; - } - } - - const client = new HangingInterruptClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - rescueTimeouts: { interruptSessionMs: 10 }, - idFactory: () => "00000000-0000-4000-8000-000000000303", +test("cancelAgentRun preserves running state when the provider interrupt hangs", async () => { + const fixture = await createControlledInterruptFixture({ + name: "interrupt-timeout", + agentId: "00000000-0000-4000-8000-000000000303", + turnId: "hanging-interrupt-turn", + interrupt: async () => await new Promise(() => {}), }); try { - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - }, - undefined, - { workspaceId: undefined }, - ); + const running = waitForAgentLifecycle(fixture.manager, fixture.agentId, "running"); + fixture.session.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "hanging-interrupt-turn", + }); + await running; - await new Promise((resolve) => { - const unsubscribe = manager.subscribe( - (event) => { - if ( - event.type === "agent_state" && - event.agent.id === snapshot.id && - event.agent.lifecycle === "running" - ) { - unsubscribe(); - resolve(); - } - }, - { agentId: snapshot.id, replayState: false }, - ); - client.session.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: "hanging-interrupt-turn", - }); + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "refused", + }); + expect(fixture.session.interruptCalled).toBe(true); + expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("running"); + } finally { + fixture.cleanup(); + } +}); + +test("cancelAgentRun preserves the active turn when the provider rejects the interrupt", async () => { + const fixture = await createControlledInterruptFixture({ + name: "interrupt-rejected", + agentId: "00000000-0000-4000-8000-000000000304", + turnId: "provider-still-active-turn", + interrupt: async () => { + throw new Error("A foreground turn is already active"); + }, + }); + + try { + await fixture.startForegroundRun(); + + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "refused", + }); + expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({ + lifecycle: "running", + activeForegroundTurnId: "provider-still-active-turn", }); - await expect(manager.cancelAgentRun(snapshot.id)).resolves.toBe(true); - expect(client.session.interruptCalled).toBe(true); + fixture.session.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: "provider-still-active-turn", + }); } finally { - rmSync(workdir, { recursive: true, force: true }); + fixture.cleanup(); + } +}); + +test("cancelAgentRun succeeds when the foreground turn finishes before the provider rejects the interrupt", async () => { + let fixture!: ControlledInterruptFixture; + fixture = await createControlledInterruptFixture({ + name: "interrupt-after-completion", + agentId: "00000000-0000-4000-8000-000000000305", + turnId: "naturally-completed-turn", + interrupt: async (session) => { + const settled = waitForAgentLifecycle(fixture.manager, fixture.agentId, "idle"); + session.pushEvent({ + type: "turn_completed", + provider: session.provider, + turnId: "naturally-completed-turn", + }); + await settled; + throw new Error("turn already completed"); + }, + }); + + try { + await fixture.startForegroundRun(); + + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "settled", + }); + expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({ + lifecycle: "idle", + activeForegroundTurnId: null, + }); + } finally { + fixture.cleanup(); + } +}); + +test("cancelAgentRun succeeds when the provider queues completion before rejecting the interrupt", async () => { + const fixture = await createControlledInterruptFixture({ + name: "interrupt-queued-completion", + agentId: "00000000-0000-4000-8000-000000000306", + turnId: "queued-completion-turn", + interrupt: async (session) => { + session.pushEvent({ + type: "turn_completed", + provider: session.provider, + turnId: "queued-completion-turn", + }); + throw new Error("turn already completed"); + }, + }); + + try { + await fixture.startForegroundRun(); + + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "settled", + }); + expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({ + lifecycle: "idle", + activeForegroundTurnId: null, + }); + } finally { + fixture.cleanup(); } }); @@ -4290,7 +4436,7 @@ test("replaceAgentRun does not emit idle or resolve waiters between interrupted await manager.waitForAgentRunStart(snapshot.id); const waitPromise = manager.waitForAgentEvent(snapshot.id); - const secondRun = manager.replaceAgentRun(snapshot.id, "second run"); + const secondRun = await manager.replaceAgentRun(snapshot.id, "second run"); const secondRunDrain = (async () => { for await (const _event of secondRun) { // Drain replacement run. @@ -4421,12 +4567,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the const replaceUpdatesStart = stateUpdates.length; const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0; - const secondRun = manager.replaceAgentRun(snapshot.id, "replacement run"); - const secondRunDrain = (async () => { - for await (const _event of secondRun) { - // Drain replacement run. - } - })(); + const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run"); await interruptStarted.promise; const replacementUpdates = stateUpdates.slice(replaceUpdatesStart); @@ -4438,15 +4579,28 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the expect(replacementUpdates.map((update) => update.lifecycle)).not.toContain("idle"); allowInterruptToFinish.resolve(); + const secondRun = await secondRunPromise; + const secondRunDrain = (async () => { + for await (const _event of secondRun) { + // Drain replacement run. + } + })(); await secondStartEntered.promise; const replaceGapSnapshot = manager.getAgent(snapshot.id) as | { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string } | undefined; - expect(replaceGapSnapshot?.pendingReplacement).toBe(false); + expect(replaceGapSnapshot?.pendingReplacement).toBe(true); expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull(); expect(replaceGapSnapshot?.lifecycle).toBe("running"); + const replacementStart = manager.waitForAgentRunStart(snapshot.id); + const prematureStart = await Promise.race([ + replacementStart.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), + ]); + expect(prematureStart).toBe("pending"); + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" }); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -4458,7 +4612,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the allowSecondStartToResolve.resolve(); - await manager.waitForAgentRunStart(snapshot.id); + await replacementStart; await firstRunDrain; await secondRunDrain; unsubscribe(); @@ -4546,16 +4700,18 @@ test("applies live autonomous events while no foreground run is active", async ( expect(lifecycleUpdates).toContain("idle"); }); -test("cancelAgentRun can interrupt autonomous running state without a foreground activeForegroundTurnId", async () => { +test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); class LiveInterruptSession extends TestAgentSession { public interruptCount = 0; + readonly interruptCalled = deferred(); override async interrupt(): Promise { this.interruptCount += 1; + this.interruptCalled.resolve(undefined); } } @@ -4618,9 +4774,80 @@ test("cancelAgentRun can interrupt autonomous running state without a foreground expect(beforeCancel?.lifecycle).toBe("running"); expect(beforeCancel?.activeForegroundTurnId).toBeNull(); - const cancelled = await manager.cancelAgentRun(snapshot.id); - expect(cancelled).toBe(true); + let cancelSettled = false; + const cancelPromise = manager.cancelAgentRun(snapshot.id).finally(() => { + cancelSettled = true; + }); + await capturedSession.interruptCalled.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(cancelSettled).toBe(false); expect(client.lastSession?.interruptCount).toBe(1); + + capturedSession.pushEvent({ + type: "turn_canceled", + provider: "codex", + turnId: "autonomous-cancel-1", + reason: "interrupted", + }); + + await expect(cancelPromise).resolves.toEqual({ status: "settled" }); + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); +}); + +test("failed replacement cancellation preserves an autonomous running state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-replace-rejected-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + + class RejectingLiveInterruptSession extends TestAgentSession { + override async interrupt(): Promise { + throw new Error("provider still owns the autonomous turn"); + } + } + + class RejectingLiveInterruptClient extends TestAgentClient { + readonly session = new RejectingLiveInterruptSession({ + provider: "codex", + cwd: workdir, + }); + + override async createSession(): Promise { + return this.session; + } + } + + const client = new RejectingLiveInterruptClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + rescueTimeouts: { interruptSessionMs: 10 }, + idFactory: () => "00000000-0000-4000-8000-000000000130", + }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const running = waitForAgentLifecycle(manager, agent.id, "running"); + + client.session.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "autonomous-replace-1", + }); + await running; + + await expect(manager.replaceAgentRun(agent.id, "replacement prompt")).rejects.toThrow( + `Cannot replace agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + expect(manager.getAgent(agent.id)).toMatchObject({ + lifecycle: "running", + activeForegroundTurnId: null, + }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } }); test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => { @@ -7217,7 +7444,7 @@ test("replaceAgentRun succeeds when foreground turn terminal event is never deli // Replace the hung run. cancelAgentRun will time out after 2s because // no terminal event arrives. After the fix, it should force-clear the // stale foreground state so streamAgent can proceed. - const secondRun = manager.replaceAgentRun(snapshot.id, "replacement prompt"); + const secondRun = await manager.replaceAgentRun(snapshot.id, "replacement prompt"); const collectedEvents: AgentStreamEvent[] = []; const secondRunDrain = (async () => { for await (const event of secondRun) { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f74bbdc99..fa34cbaa1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -59,7 +59,7 @@ import { AgentStreamCoalescer, } from "./agent-stream-coalescer.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; -import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js"; +import { AgentRunState, type ForegroundTurnWaiter } from "./agent-run-state.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js"; @@ -95,6 +95,20 @@ export class AgentManagerShuttingDownError extends Error { } } +export class AgentRunCancellationError extends Error { + constructor(agentId: string, action: "reload" | "replace" | "rewind" | "stop") { + super( + `Cannot ${action} agent ${agentId} because its active run cancellation was not acknowledged`, + ); + this.name = "AgentRunCancellationError"; + } +} + +export type AgentRunCancellationResult = + | { status: "not_running" } + | { status: "settled" } + | { status: "refused" }; + interface PreparedSessionConfig { storedConfig: AgentSessionConfig; launchConfig: AgentSessionConfig; @@ -543,7 +557,7 @@ export class AgentManager { private readonly providerSubagents = new ProviderSubagentStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); - private readonly foregroundRuns = new ForegroundRunState(); + private readonly runs = new AgentRunState(); private readonly subscribers = new Set(); private readonly idFactory: () => string; private readonly registry?: AgentStorage; @@ -722,7 +736,7 @@ export class AgentManager { return ( agent.lifecycle === "running" || Boolean(agent.activeForegroundTurnId) || - this.foregroundRuns.hasPendingRun(agentId) + this.runs.hasRun(agentId) ); } @@ -1161,7 +1175,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); let existing = this.requireSessionAgent(agentId); if (this.hasInFlightRun(agentId)) { - await this.cancelAgentRun(agentId); + await this.cancelAgentRunBefore(agentId, "reload"); existing = this.requireSessionAgent(agentId); } const rehydrateFromDisk = options?.rehydrateFromDisk ?? false; @@ -1836,13 +1850,13 @@ export class AgentManager { turnId: existingAgent.activeForegroundTurnId ?? undefined, lifecycle: existingAgent.lifecycle, activeForegroundTurnId: existingAgent.activeForegroundTurnId, - hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId), + hasTrackedRun: this.runs.hasRun(agentId), promptType: typeof prompt === "string" ? "string" : "structured", hasRunOptions: Boolean(options), }, "agent.manager.stream.request", ); - if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) { + if (existingAgent.activeForegroundTurnId || this.runs.hasRun(agentId)) { this.logger.trace( { agentId, @@ -1850,7 +1864,7 @@ export class AgentManager { sessionId: existingAgent.persistence?.sessionId ?? undefined, turnId: existingAgent.activeForegroundTurnId ?? undefined, lifecycle: existingAgent.lifecycle, - hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId), + hasTrackedRun: this.runs.hasRun(agentId), }, "agent.manager.stream.reject", ); @@ -1858,18 +1872,19 @@ export class AgentManager { } const agent = existingAgent; - agent.pendingReplacement = false; + const isReplacement = agent.pendingReplacement; agent.lastError = undefined; - const pendingRun = this.foregroundRuns.createPendingRun(agentId); + const pendingRun = this.runs.createPendingRun(agentId); const streamForwarder = async function* streamForwarder(this: AgentManager) { let turnId: string; - let turnStream: ReturnType | null = null; + let turnStream: ReturnType | null = null; try { const result = await agent.session.startTurn(prompt, options); turnId = result.turnId; } catch (error) { + agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; await this.handleStreamEvent(agent, { type: "turn_failed", @@ -1877,11 +1892,15 @@ export class AgentManager { error: errorMsg, }); this.finalizeForegroundTurn(agent); - this.foregroundRuns.settlePendingRun(agentId, pendingRun.token); + this.runs.settleForegroundRun(agentId, pendingRun.token); throw error; } pendingRun.started = true; + pendingRun.turnId = turnId; + if (isReplacement) { + agent.pendingReplacement = false; + } agent.activeForegroundTurnId = turnId; agent.lifecycle = "running"; this.touchUpdatedAt(agent); @@ -1898,8 +1917,8 @@ export class AgentManager { "agent.manager.stream.start", ); - turnStream = this.foregroundRuns.createTurnStream(turnId); - this.foregroundRuns.addWaiter(agent, turnStream.waiter); + turnStream = this.runs.createTurnStream(turnId); + this.runs.addWaiter(agent, turnStream.waiter); try { for await (const event of turnStream.events(isTurnTerminalEvent)) { @@ -1907,9 +1926,9 @@ export class AgentManager { } } finally { if (turnStream) { - this.foregroundRuns.deleteWaiter(agent, turnStream.waiter); + this.runs.deleteWaiter(agent, turnStream.waiter); } - this.foregroundRuns.settlePendingRun(agentId, pendingRun.token); + this.runs.settleForegroundRun(agentId, pendingRun.token); if (!agent.activeForegroundTurnId) { await this.refreshRuntimeInfo(agent); } @@ -1922,7 +1941,7 @@ export class AgentManager { private finalizeForegroundTurn(agent: ActiveManagedAgent, turnId?: string): void { const mutableAgent = agent; if (turnId) { - this.foregroundRuns.rememberFinalizedTurn(mutableAgent, turnId); + this.runs.rememberFinalizedTurn(mutableAgent, turnId); } mutableAgent.activeForegroundTurnId = null; const terminalError = mutableAgent.lastError; @@ -1962,16 +1981,16 @@ export class AgentManager { } } - replaceAgentRun( + async replaceAgentRun( agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions, - ): AsyncGenerator { + ): Promise> { const snapshot = this.requireAgent(agentId); if ( snapshot.lifecycle !== "running" && !snapshot.activeForegroundTurnId && - !this.foregroundRuns.hasPendingRun(agentId) + !this.runs.hasRun(agentId) ) { return this.streamAgent(agentId, prompt, options); } @@ -1982,27 +2001,16 @@ export class AgentManager { this.touchUpdatedAt(agent); this.emitState(agent); - return async function* replaceRunForwarder(this: AgentManager) { - try { - await this.cancelAgentRun(agentId); - const nextRun = this.streamAgent(agentId, prompt, options); - for await (const event of nextRun) { - yield event; - } - } catch (error) { - const latest = this.agents.get(agentId); - if (latest) { - const latestActive = latest; - latestActive.pendingReplacement = false; - if (!latestActive.activeForegroundTurnId && latestActive.lifecycle === "running") { - (latestActive as ActiveManagedAgent).lifecycle = "idle"; - this.touchUpdatedAt(latestActive); - this.emitState(latestActive); - } - } - throw error; + try { + await this.cancelAgentRunBefore(agentId, "replace"); + return this.streamAgent(agentId, prompt, options); + } catch (error) { + const latest = this.agents.get(agentId); + if (latest) { + latest.pendingReplacement = false; } - }.call(this); + throw error; + } } async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise { @@ -2011,7 +2019,7 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found`); } - const pendingRun = this.foregroundRuns.getPendingRun(agentId); + const pendingRun = this.runs.getPendingRun(agentId); if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) { return; } @@ -2075,7 +2083,7 @@ export class AgentManager { return true; } - const currentPendingRun = this.foregroundRuns.getPendingRun(agentId); + const currentPendingRun = this.runs.getPendingRun(agentId); if ( (current.lifecycle === "running" || currentPendingRun?.started) && !current.pendingReplacement @@ -2146,106 +2154,70 @@ export class AgentManager { } } - async cancelAgentRun(agentId: string): Promise { + async cancelAgentRun(agentId: string): Promise { const agent = this.requireSessionAgent(agentId); - const pendingRun = this.foregroundRuns.getPendingRun(agentId); - const foregroundTurnId = agent.activeForegroundTurnId; - const hasForegroundTurn = Boolean(foregroundTurnId); - const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun; - - if (!hasForegroundTurn && !isAutonomousRunning && !pendingRun) { - return false; + const run = + this.runs.getRun(agentId) ?? + (agent.lifecycle === "running" ? this.runs.trackAutonomousRun(agentId, null) : null); + if (!run) { + return { status: "not_running" }; } - await this.interruptSession(agent.session, agentId); + const interruptAcknowledged = await this.interruptSession(agent.session, agentId); + const settlement = await this.waitWithTimeout({ + operation: run.settledPromise, + timeoutMs: interruptAcknowledged + ? INTERRUPT_SESSION_TIMEOUT_MS + : this.rescueTimeouts.interruptSessionMs, + }); - // The interrupt will produce a turn_canceled/turn_failed event via subscribe(), - // which flows through the session event dispatcher and settles the foreground turn waiter. - // Wait briefly for the event to propagate if there's an active foreground turn. - if (foregroundTurnId) { - const waiter = Array.from(agent.foregroundTurnWaiters).find( - (candidate) => candidate.turnId === foregroundTurnId, - ); - const timeout = new Promise((resolvePromise) => setTimeout(resolvePromise, 2000)); - if (waiter) { - await Promise.race([waiter.settledPromise, timeout]); - } else if (agent.activeForegroundTurnId === foregroundTurnId) { - await Promise.race([ - new Promise((resolvePromise) => { - const unsubscribe = this.subscribe( - (event) => { - if ( - event.type === "agent_state" && - event.agent.id === agentId && - !event.agent.activeForegroundTurnId - ) { - unsubscribe(); - resolvePromise(); - } - }, - { agentId, replayState: false }, - ); - }), - timeout, - ]); - } - // The waiter settling wakes up the streamForwarder generator, but its - // finally block (which deletes the pendingForegroundRun) runs asynchronously. - // Wait for the pending run to be fully cleaned up so the next streamAgent - // call doesn't see a stale entry and reject with "already has an active run". - if (pendingRun && !pendingRun.settled) { - await Promise.race([pendingRun.settledPromise, timeout]); - } - } else if (pendingRun) { - const timeout = new Promise((resolvePromise) => setTimeout(resolvePromise, 2000)); - await Promise.race([pendingRun.settledPromise, timeout]); + if (!interruptAcknowledged) { + return { status: settlement === "completed" ? "settled" : "refused" }; } - // If the foreground turn is still stuck after the timeout, force-dispatch a - // synthetic turn_canceled so the normal event pipeline cleans up - // activeForegroundTurnId, settles waiters, and unblocks the streamForwarder. - if (foregroundTurnId && agent.activeForegroundTurnId === foregroundTurnId) { + if (settlement === "timed_out" && run.turnId) { this.logger.warn( - { agentId, foregroundTurnId }, - "cancelAgentRun: foreground turn still active after timeout, force-canceling", + { agentId, turnId: run.turnId, kind: run.kind }, + "cancelAgentRun: acknowledged turn still active after timeout, force-canceling", ); - void this.dispatchSessionEvent(agent, { + await this.dispatchSessionEvent(agent, { + type: "turn_canceled", + provider: agent.provider, + reason: "interrupted", + turnId: run.turnId, + }); + await run.settledPromise; + } else if (settlement === "timed_out" && run.kind === "autonomous") { + this.logger.warn( + { agentId, kind: run.kind }, + "cancelAgentRun: acknowledged turn still active after timeout, force-canceling", + ); + await this.dispatchSessionEvent(agent, { type: "turn_canceled", provider: agent.provider, reason: "interrupted", - turnId: foregroundTurnId, }); - // The synthetic event unblocks the streamForwarder generator, whose finally - // block settles the pending foreground run asynchronously. Wait for it. - const staleRun = this.foregroundRuns.getPendingRun(agentId); - if (staleRun && !staleRun.settled) { - await staleRun.settledPromise; - } } - // Clear any pending permissions that weren't cleaned up by handleStreamEvent. if (agent.pendingPermissions.size > 0) { - for (const [requestId] of agent.pendingPermissions) { - this.dispatchStream( - agent.id, - { - type: "permission_resolved", - provider: agent.provider, - requestId, - resolution: { behavior: "deny", message: "Interrupted" }, - }, - { timestamp: new Date().toISOString() }, - ); - } - agent.pendingPermissions.clear(); + this.resolvePendingPermissionsForAgent(agent, agent.provider, undefined, "Interrupted"); this.touchUpdatedAt(agent); this.emitState(agent); } - - return true; + return { status: "settled" }; } - private async interruptSession(session: AgentSession, agentId: string): Promise { + private async cancelAgentRunBefore( + agentId: string, + action: "reload" | "replace" | "rewind", + ): Promise { + const result = await this.cancelAgentRun(agentId); + if (result.status === "refused") { + throw new AgentRunCancellationError(agentId, action); + } + } + + private async interruptSession(session: AgentSession, agentId: string): Promise { try { const result = await this.waitWithTimeout({ operation: session.interrupt(), @@ -2263,9 +2235,12 @@ export class AgentManager { { agentId, timeoutMs: this.rescueTimeouts.interruptSessionMs }, "Timed out interrupting session during cancel", ); + return false; } + return true; } catch (error) { this.logger.error({ err: error, agentId }, "Failed to interrupt session"); + return false; } } @@ -2294,13 +2269,11 @@ export class AgentManager { async rewind(agentId: string, messageId: string, mode: RewindMode): Promise { const agent = this.requireSessionAgent(agentId); - const hadActiveRun = - Boolean(agent.activeForegroundTurnId) || this.foregroundRuns.hasPendingRun(agentId); - if (hadActiveRun) { - await this.cancelAgentRun(agentId); + if (this.hasInFlightRun(agentId)) { + await this.cancelAgentRunBefore(agentId, "rewind"); } - const lock = this.foregroundRuns.createPendingRun(agentId); + const lock = this.runs.createPendingRun(agentId); try { this.logger.info( { agentId, provider: agent.provider, messageId, mode }, @@ -2323,7 +2296,7 @@ export class AgentManager { ); throw error; } finally { - this.foregroundRuns.settlePendingRun(agentId, lock.token); + this.runs.settleForegroundRun(agentId, lock.token); } } @@ -2421,7 +2394,7 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found`); } - const pendingForegroundRun = this.foregroundRuns.getPendingRun(agentId); + const pendingForegroundRun = this.runs.getPendingRun(agentId); const hasForegroundTurn = Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun); @@ -2793,13 +2766,13 @@ export class AgentManager { agent.unsubscribeSession(); agent.unsubscribeSession = null; } - this.foregroundRuns.cancelWaiters(agent, (turnId) => ({ + this.runs.cancelWaiters(agent, (turnId) => ({ type: "turn_canceled", provider: agent.provider, reason: cancelReason, turnId, })); - this.foregroundRuns.settlePendingRun(agent.id); + this.runs.clearAgentRun(agent.id); return { ...agent, lifecycle: "closed", @@ -2883,7 +2856,7 @@ export class AgentManager { return; } const turnId = getAgentStreamEventTurnId(event); - const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId); + const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId); this.logger.trace( { agentId: agent.id, @@ -2902,7 +2875,7 @@ export class AgentManager { return; } - this.foregroundRuns.notifyWaiters(matchingWaiters, event, { + this.runs.notifyWaiters(matchingWaiters, event, { terminal: isTurnTerminalEvent(event), }); this.logger.trace( @@ -3121,7 +3094,7 @@ export class AgentManager { return; } - this.foregroundRuns.notifyAgentWaiters(agent, event); + this.runs.notifyAgentWaiters(agent, event); this.logger.trace( { agentId, @@ -3151,7 +3124,7 @@ export class AgentManager { if ( eventTurnId && isTurnTerminalEvent(event) && - this.foregroundRuns.hasFinalizedTurn(agent, eventTurnId) + this.runs.hasFinalizedTurn(agent, eventTurnId) ) { return false; } @@ -3180,8 +3153,11 @@ export class AgentManager { await dispatchPromise; } - if (!options?.fromHistory && isForegroundEvent && isTurnTerminalEvent(event)) { - this.finalizeForegroundTurn(agent, eventTurnId); + if (!options?.fromHistory && isTurnTerminalEvent(event)) { + this.runs.settleTerminalRun(agent.id, eventTurnId); + if (isForegroundEvent) { + this.finalizeForegroundTurn(agent, eventTurnId); + } } if (!options?.fromHistory && flags.shouldDispatchEvent) { @@ -3496,6 +3472,7 @@ export class AgentManager { "agent.manager.turn.started", ); if (!isForegroundEvent) { + this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null); agent.lifecycle = "running"; this.emitState(agent); } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index a88a8b9a3..d726509a8 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,4 +1,5 @@ import { expect, it, test, vi } from "vitest"; +import pino, { type Logger } from "pino"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; @@ -11,8 +12,34 @@ import { } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; +interface CapturedLogger { + logger: Logger; + records: Array>; + nextRecord: Promise; +} + +function createCapturedLogger(): CapturedLogger { + const records: Array> = []; + let resolveNextRecord!: () => void; + const nextRecord = new Promise((resolve) => { + resolveNextRecord = resolve; + }); + const logger = pino( + { level: "error" }, + { + write(line: string) { + records.push(JSON.parse(line) as Record); + resolveNextRecord(); + }, + }, + ); + return { logger, records, nextRecord }; +} + interface FinishNotificationScenarioOptions { childLastAssistantMessage?: string | null; + parentPromptError?: Error; + logger?: Logger; } interface FinishNotificationScenario { @@ -56,11 +83,15 @@ function createFinishNotificationScenario( return options?.childLastAssistantMessage ?? null; }); Reflect.set(agentManager, "tryRunOutOfBand", () => false); - Reflect.set(agentManager, "hasInFlightRun", () => false); + Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { resolveParentPrompt?.(prompt); return (async function* noop() {})(); }); + Reflect.set(agentManager, "replaceAgentRun", async (_agentId: string, prompt: string) => { + resolveParentPrompt?.(prompt); + throw options?.parentPromptError; + }); const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); Reflect.set(agentStorage, "get", async (agentId: string) => { @@ -77,7 +108,7 @@ function createFinishNotificationScenario( agentStorage, childAgentId: "child-agent", callerAgentId: "caller-agent", - logger: createTestLogger(), + logger: options?.logger ?? createTestLogger(), }); }, async finishChildAndReadParentPrompt() { @@ -161,6 +192,28 @@ test("finish notifications tell the parent the child's last assistant message", ); }); +test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => { + const captured = createCapturedLogger(); + const scenario = createFinishNotificationScenario({ + parentPromptError: new Error("parent provider rejected replacement"), + logger: captured.logger, + }); + + scenario.startWatchingChild(); + await scenario.finishChildAndReadParentPrompt(); + await captured.nextRecord; + + expect(captured.records).toEqual([ + expect.objectContaining({ + msg: "Failed to notify caller agent", + childAgentId: "child-agent", + callerAgentId: "caller-agent", + reason: "finished", + err: expect.objectContaining({ message: "parent provider rejected replacement" }), + }), + ]); +}); + it("does not notify archived callers", async () => { let subscriber: ((event: AgentManagerEvent) => void) | null = null; diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 74664b630..0178ee578 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -15,13 +15,13 @@ export interface StartAgentRunOptions { runOptions?: AgentRunOptions; } -export function startAgentRun( +export async function startAgentRun( agentManager: AgentRunController, agentId: string, prompt: AgentPromptInput, logger: Logger, options?: StartAgentRunOptions, -): { outOfBand: boolean } { +): Promise<{ outOfBand: boolean }> { const snapshot = agentManager.getAgent(agentId); logger.trace( { @@ -44,7 +44,7 @@ export function startAgentRun( const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); const runOptions = options?.runOptions; const iterator = shouldReplace - ? agentManager.replaceAgentRun(agentId, prompt, runOptions) + ? await agentManager.replaceAgentRun(agentId, prompt, runOptions) : agentManager.streamAgent(agentId, prompt, runOptions); logger.trace( { @@ -197,7 +197,7 @@ export async function sendPromptToAgent( ? { ...params.runOptions, messageId: params.messageId } : params.runOptions; - return startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { + return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { replaceRunning: true, runOptions, }); @@ -215,7 +215,7 @@ export async function startCreatedAgentInitialPrompt( return currentSnapshot; } - const dispatchResult = startAgentRun( + const dispatchResult = await startAgentRun( params.agentManager, params.agentId, params.prompt, @@ -298,6 +298,15 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } + function notifySafely(reason: "finished" | "errored" | "needs permission"): void { + void notify(reason).catch((error) => { + logger.error( + { err: error, childAgentId, callerAgentId, reason }, + "Failed to notify caller agent", + ); + }); + } + unsubscribe = agentManager.subscribe( (event) => { if (fired) { @@ -310,11 +319,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } if (event.agent.lifecycle === "error") { - void notify("errored"); + notifySafely("errored"); return; } if (event.agent.lifecycle === "idle" && hasSeenRunning) { - void notify("finished"); + notifySafely("finished"); return; } if (event.agent.lifecycle === "closed") { @@ -326,7 +335,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } if (event.event.type === "permission_requested") { - void notify("needs permission"); + notifySafely("needs permission"); } }, { agentId: childAgentId, replayState: false }, @@ -345,6 +354,6 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): if (childSnapshot.lifecycle === "running") { hasSeenRunning = true; } else if (childSnapshot.lifecycle === "error") { - void notify("errored"); + notifySafely("errored"); } } diff --git a/packages/server/src/server/agent/foreground-run-state.ts b/packages/server/src/server/agent/agent-run-state.ts similarity index 65% rename from packages/server/src/server/agent/foreground-run-state.ts rename to packages/server/src/server/agent/agent-run-state.ts index 705c59a38..e74731718 100644 --- a/packages/server/src/server/agent/foreground-run-state.ts +++ b/packages/server/src/server/agent/agent-run-state.ts @@ -12,45 +12,102 @@ export interface ForegroundTurnWaiter { export interface PendingForegroundRun { token: string; + kind: "foreground"; + turnId: string | null; started: boolean; settled: boolean; settledPromise: Promise; resolveSettled: () => void; } +export interface AutonomousAgentRun { + token: string; + kind: "autonomous"; + turnId: string | null; + started: true; + settled: boolean; + settledPromise: Promise; + resolveSettled: () => void; +} + +export type TrackedAgentRun = PendingForegroundRun | AutonomousAgentRun; + export interface ForegroundRunAgentState { foregroundTurnWaiters: Set; finalizedForegroundTurnIds: Set; } -export class ForegroundRunState { - private readonly pendingRuns = new Map(); +export class AgentRunState { + private readonly runs = new Map(); createPendingRun(agentId: string): PendingForegroundRun { const pendingRun = createPendingForegroundRun(); - this.pendingRuns.set(agentId, pendingRun); + this.runs.set(agentId, pendingRun); return pendingRun; } getPendingRun(agentId: string): PendingForegroundRun | null { - return this.pendingRuns.get(agentId) ?? null; + const run = this.runs.get(agentId); + return run?.kind === "foreground" ? run : null; } hasPendingRun(agentId: string): boolean { - return this.pendingRuns.has(agentId); + return this.getPendingRun(agentId) !== null; } - settlePendingRun(agentId: string, token?: string): void { - const pendingRun = this.pendingRuns.get(agentId); - if (!pendingRun) { + getRun(agentId: string): TrackedAgentRun | null { + return this.runs.get(agentId) ?? null; + } + + hasRun(agentId: string): boolean { + return this.runs.has(agentId); + } + + trackAutonomousRun(agentId: string, turnId: string | null): TrackedAgentRun { + const current = this.runs.get(agentId); + if (current) { + return current; + } + + const run = createTrackedRun({ kind: "autonomous", turnId, started: true }); + this.runs.set(agentId, run); + return run; + } + + settleTerminalRun(agentId: string, turnId: string | undefined): void { + const run = this.runs.get(agentId); + if (!run) { return; } - if (token && pendingRun.token !== token) { + if (run.kind === "foreground" && (run.turnId === null || run.turnId !== turnId)) { + return; + } + if ( + run.kind === "autonomous" && + run.turnId !== null && + turnId !== undefined && + run.turnId !== turnId + ) { return; } - this.pendingRuns.delete(agentId); - settlePendingForegroundRun(pendingRun); + this.clearRun(agentId, run); + } + + settleForegroundRun(agentId: string, token: string): void { + const run = this.runs.get(agentId); + if (run?.kind !== "foreground" || run.token !== token) { + return; + } + + this.clearRun(agentId, run); + } + + clearAgentRun(agentId: string): void { + const run = this.runs.get(agentId); + if (run) { + this.clearRun(agentId, run); + } } createTurnStream(turnId: string): ForegroundTurnStream { @@ -120,14 +177,6 @@ export class ForegroundRunState { agent.foregroundTurnWaiters.clear(); } - clearAgent(agentId: string, agent: ForegroundRunAgentState): void { - for (const waiter of agent.foregroundTurnWaiters) { - this.settleWaiter(waiter); - } - agent.foregroundTurnWaiters.clear(); - this.settlePendingRun(agentId); - } - rememberFinalizedTurn(agent: ForegroundRunAgentState, turnId: string): void { agent.finalizedForegroundTurnIds.add(turnId); if (agent.finalizedForegroundTurnIds.size <= 50) { @@ -143,6 +192,11 @@ export class ForegroundRunState { hasFinalizedTurn(agent: ForegroundRunAgentState, turnId: string): boolean { return agent.finalizedForegroundTurnIds.has(turnId); } + + private clearRun(agentId: string, run: TrackedAgentRun): void { + this.runs.delete(agentId); + settleTrackedRun(run); + } } export class ForegroundTurnStream { @@ -205,24 +259,42 @@ export class ForegroundTurnStream { } function createPendingForegroundRun(): PendingForegroundRun { + return createTrackedRun({ kind: "foreground", turnId: null, started: false }); +} + +function createTrackedRun(input: { + kind: "foreground"; + turnId: null; + started: false; +}): PendingForegroundRun; +function createTrackedRun(input: { + kind: "autonomous"; + turnId: string | null; + started: true; +}): AutonomousAgentRun; +function createTrackedRun( + input: + | { kind: "foreground"; turnId: null; started: false } + | { kind: "autonomous"; turnId: string | null; started: true }, +): TrackedAgentRun { let resolveSettled!: () => void; const settledPromise = new Promise((resolvePromise) => { resolveSettled = resolvePromise; }); return { token: randomUUID(), - started: false, + ...input, settled: false, settledPromise, resolveSettled, }; } -function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void { - if (pendingRun.settled) { +function settleTrackedRun(run: TrackedAgentRun): void { + if (run.settled) { return; } - pendingRun.settled = true; - pendingRun.resolveSettled(); + run.settled = true; + run.resolveSettled(); } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 60c4d2df6..7954ec1d4 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -13,16 +13,12 @@ import type { } from "../../worktree-session.js"; import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; -import type { - AgentPromptContentBlock, - AgentPromptInput, - AgentRunOptions, - AgentSessionConfig, -} from "../agent-sdk-types.js"; +import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveCreateAgentTitles } from "../create-agent-title.js"; +import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; import { @@ -486,30 +482,6 @@ async function sendInitialPrompt( } } -function buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], -): AgentPromptInput { - const normalized = text.trim(); - const hasImages = (images?.length ?? 0) > 0; - const hasAttachments = (attachments?.length ?? 0) > 0; - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; -} - function requireParentAgent(agentManager: AgentManager, parentAgentId: string): ManagedAgent { const parentAgent = agentManager.getAgent(parentAgentId); if (!parentAgent) { diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index 9a3da4aa6..6e5714d48 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -43,6 +43,8 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { readonly modeUpdates: Array<{ agentId: string; modeId: string }> = []; readonly detachedAgentIds: string[] = []; inFlightAgentIds = new Set(); + readonly settledDuringCancellationAgentIds = new Set(); + readonly rejectedCancellationAgentIds = new Set(); constructor(private readonly storage: FakeLifecycleAgentStorage) {} @@ -54,9 +56,18 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { return this.inFlightAgentIds.has(agentId); } - async cancelAgentRun(agentId: string): Promise { + async cancelAgentRun(agentId: string) { this.cancelledAgentIds.push(agentId); - return this.inFlightAgentIds.delete(agentId); + if (this.settledDuringCancellationAgentIds.delete(agentId)) { + this.inFlightAgentIds.delete(agentId); + return { status: "not_running" } as const; + } + if (this.rejectedCancellationAgentIds.has(agentId)) { + return { status: "refused" } as const; + } + return this.inFlightAgentIds.delete(agentId) + ? ({ status: "settled" } as const) + : ({ status: "not_running" } as const); } async clearAgentAttention(agentId: string): Promise { @@ -168,6 +179,21 @@ describe("agent lifecycle commands", () => { expect(manager.cancelledAgentIds).toEqual(["agent-1"]); }); + test("accepts a stop when the run settles during cancellation", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", managedAgent("agent-1", "running")); + manager.inFlightAgentIds.add("agent-1"); + manager.settledDuringCancellationAgentIds.add("agent-1"); + + await expect( + cancelAgentRunCommand({ agentManager: manager, logger }, "agent-1"), + ).resolves.toEqual({ + agent: manager.liveAgents.get("agent-1"), + cancelled: false, + }); + }); + test("archives a live agent after canceling and clearing attention", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); @@ -193,6 +219,21 @@ describe("agent lifecycle commands", () => { expect(manager.archivedAgentIds).toEqual(["agent-1"]); }); + test("archives a live agent when its graceful cancellation is rejected", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", managedAgent("agent-1", "running")); + manager.inFlightAgentIds.add("agent-1"); + manager.rejectedCancellationAgentIds.add("agent-1"); + storage.records.set("agent-1", storedAgent("agent-1")); + + await expect( + archiveAgentCommand({ agentManager: manager, agentStorage: storage, logger }, "agent-1"), + ).resolves.toMatchObject({ agentId: "agent-1" }); + expect(manager.cancelledAgentIds).toEqual(["agent-1"]); + expect(manager.archivedAgentIds).toEqual(["agent-1"]); + }); + test("archives a stored agent when no live agent exists", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index b6318c383..cb172ce62 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -1,6 +1,10 @@ import type { Logger } from "pino"; -import type { ManagedAgent } from "./agent-manager.js"; +import { + AgentRunCancellationError, + type AgentRunCancellationResult, + type ManagedAgent, +} from "./agent-manager.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentProviderNotice } from "./agent-sdk-types.js"; @@ -9,7 +13,7 @@ export type LifecycleAgentSnapshot = Pick; + cancelAgentRun(agentId: string): Promise; clearAgentAttention(agentId: string): Promise; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; archiveSnapshot(agentId: string, archivedAt: string): Promise; @@ -47,10 +51,14 @@ export interface CancelAgentRunResult { cancelled: boolean; } -export async function cancelAgentRunCommand( +interface RequestedAgentRunCancellation extends CancelAgentRunResult { + cancellation: AgentRunCancellationResult; +} + +async function requestAgentRunCancellation( dependencies: Pick, agentId: string, -): Promise { +): Promise { const { agentManager, logger } = dependencies; const agent = agentManager.getAgent(agentId); if (!agent) { @@ -64,7 +72,7 @@ export async function cancelAgentRunCommand( { agentId, lifecycle: agent.lifecycle, hasInFlightRun }, "cancelAgentRunCommand: skipping because agent is not running", ); - return { agent, cancelled: false }; + return { agent, cancelled: false, cancellation: { status: "not_running" } }; } logger.debug( @@ -72,23 +80,33 @@ export async function cancelAgentRunCommand( "cancelAgentRunCommand: interrupting", ); const startedAt = Date.now(); - const cancelled = await agentManager.cancelAgentRun(agentId); + const cancellation = await agentManager.cancelAgentRun(agentId); logger.debug( - { agentId, cancelled, durationMs: Date.now() - startedAt }, + { agentId, cancellation: cancellation.status, durationMs: Date.now() - startedAt }, "cancelAgentRunCommand: cancelAgentRun completed", ); - if (!cancelled) { - logger.warn( + return { + agent, + cancelled: cancellation.status === "settled", + cancellation, + }; +} + +export async function cancelAgentRunCommand( + dependencies: Pick, + agentId: string, +): Promise { + const result = await requestAgentRunCancellation(dependencies, agentId); + if (result.cancellation.status === "refused") { + dependencies.logger.warn( { agentId }, "cancelAgentRunCommand: reported running but no active run was cancelled", ); + throw new AgentRunCancellationError(agentId, "stop"); } - return { - agent, - cancelled, - }; + return { agent: result.agent, cancelled: result.cancelled }; } export interface ArchiveAgentResult { @@ -104,7 +122,7 @@ export async function archiveAgentCommand( const liveAgent = dependencies.agentManager.getAgent(agentId); let record: StoredAgentRecord | null; if (liveAgent) { - await cancelAgentRunCommand(dependencies, agentId); + await requestAgentRunCancellation(dependencies, agentId); await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); await dependencies.agentManager.archiveAgent(agentId); record = await dependencies.agentStorage.get(agentId); diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts index 89b320b00..a5cf477e6 100644 --- a/packages/server/src/server/agent/permission-response.test.ts +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -53,11 +53,11 @@ class FakePermissionAgentManager { return emptyAgentStream(); } - replaceAgentRun( + async replaceAgentRun( agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions, - ): AsyncGenerator { + ): Promise> { this.replacementRuns.push({ agentId, prompt, options }); return emptyAgentStream(); } diff --git a/packages/server/src/server/agent/permission-response.ts b/packages/server/src/server/agent/permission-response.ts index 5d473e128..7cb58e6d2 100644 --- a/packages/server/src/server/agent/permission-response.ts +++ b/packages/server/src/server/agent/permission-response.ts @@ -33,7 +33,7 @@ export async function respondToAgentPermission( if (result?.followUpPrompt) { logger.debug({ agentId }, "Permission response requires follow-up turn, starting agent stream"); - startAgentRun(agentManager, agentId, result.followUpPrompt, logger, { + await startAgentRun(agentManager, agentId, result.followUpPrompt, logger, { replaceRunning: true, }); } diff --git a/packages/server/src/server/agent/prompt-attachments.test.ts b/packages/server/src/server/agent/prompt-attachments.test.ts index d187415c0..42820c9ea 100644 --- a/packages/server/src/server/agent/prompt-attachments.test.ts +++ b/packages/server/src/server/agent/prompt-attachments.test.ts @@ -1,8 +1,42 @@ import { describe, expect, it } from "vitest"; -import { buildAgentBranchNameSeed, renderPromptAttachmentAsText } from "./prompt-attachments.js"; +import { + buildAgentBranchNameSeed, + buildAgentPrompt, + renderPromptAttachmentAsText, +} from "./prompt-attachments.js"; describe("prompt attachments", () => { + it("places fork history before the new user prompt", () => { + const chatHistory = { + type: "text" as const, + mimeType: "text/plain", + contextKind: "chat_history" as const, + title: "Chat history", + text: "\nPrevious work\n", + }; + const issue = { + type: "github_issue" as const, + mimeType: "application/github-issue", + number: 55, + title: "Issue", + url: "https://github.com/getpaseo/paseo/issues/55", + }; + + expect( + buildAgentPrompt( + " Take a different approach ", + [{ data: "image-data", mimeType: "image/png" }], + [issue, chatHistory], + ), + ).toEqual([ + chatHistory, + { type: "text", text: "Take a different approach" }, + { type: "image", data: "image-data", mimeType: "image/png" }, + issue, + ]); + }); + it("renders github_pr attachments as readable text", () => { expect( renderPromptAttachmentAsText({ diff --git a/packages/server/src/server/agent/prompt-attachments.ts b/packages/server/src/server/agent/prompt-attachments.ts index 129018f2f..c1dba8276 100644 --- a/packages/server/src/server/agent/prompt-attachments.ts +++ b/packages/server/src/server/agent/prompt-attachments.ts @@ -1,7 +1,41 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { AgentPromptContentBlock, AgentPromptInput } from "./agent-sdk-types.js"; const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const; +export function buildAgentPrompt( + text: string, + images?: Array<{ data: string; mimeType: string }>, + attachments?: AgentAttachment[], +): AgentPromptInput { + const normalized = text.trim(); + const hasImages = (images?.length ?? 0) > 0; + const hasAttachments = (attachments?.length ?? 0) > 0; + if (!hasImages && !hasAttachments) { + return normalized; + } + + const chatHistoryAttachments: AgentAttachment[] = []; + const otherAttachments: AgentAttachment[] = []; + for (const attachment of attachments ?? []) { + if (attachment.type === "text" && attachment.contextKind === "chat_history") { + chatHistoryAttachments.push(attachment); + } else { + otherAttachments.push(attachment); + } + } + + const blocks: AgentPromptContentBlock[] = [...chatHistoryAttachments]; + if (normalized.length > 0) { + blocks.push({ type: "text", text: normalized }); + } + for (const image of images ?? []) { + blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); + } + blocks.push(...otherAttachments); + return blocks; +} + export function renderPromptAttachmentAsText(attachment: AgentAttachment): string { switch (attachment.type) { case "github_pr": { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 546ff8c80..828b1b4de 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -2208,6 +2208,93 @@ describe("ACPAgentSession", () => { expect(assistantMessages[2].messageId).not.toBe(assistantMessages[0].messageId); }); + test("starts an autonomous turn for spontaneous session updates outside a foreground turn", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const session = createSession(); + asInternals(session).sessionId = "session-1"; + + const events: AgentStreamEvent[] = []; + session.subscribe((event) => { + events.push(event); + }); + + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "autonomous-msg", + content: { type: "text", text: "Autonomous update" }, + } as SessionUpdate, + }); + + // Should emit turn_started before the timeline item. + const turnStartedIndex = events.findIndex((e) => e.type === "turn_started"); + expect(turnStartedIndex).toBeGreaterThanOrEqual(0); + const timelineIndex = events.findIndex( + (e) => + e.type === "timeline" && + e.item.type === "assistant_message" && + e.item.text === "Autonomous update", + ); + expect(timelineIndex).toBeGreaterThan(turnStartedIndex); + + const turnStarted = events[turnStartedIndex]; + expect(turnStarted.type).toBe("turn_started"); + const autonomousTurnId = (turnStarted as { turnId?: string }).turnId; + expect(autonomousTurnId).toEqual(expect.any(String)); + + // Timeline item should be tagged with the autonomous turn id. + const timelineEvent = events[timelineIndex]; + expect(timelineEvent.type).toBe("timeline"); + expect((timelineEvent as { turnId?: string }).turnId).toBe(autonomousTurnId); + + // Advance timers to complete the autonomous turn. + await vi.advanceTimersByTimeAsync(ACPAgentSession["AUTONOMOUS_TURN_TIMEOUT_MS"] + 10); + const turnCompleted = events.find((e) => e.type === "turn_completed"); + expect(turnCompleted).toBeDefined(); + expect((turnCompleted as { turnId?: string }).turnId).toBe(autonomousTurnId); + + vi.useRealTimers(); + }); + + test("completes an existing autonomous turn before starting a foreground turn", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const session = createSession(); + asInternals(session).sessionId = "session-1"; + asInternals(session).connection = { + prompt: vi.fn(() => new Promise(() => {})), + }; + + const events: AgentStreamEvent[] = []; + session.subscribe((event) => { + events.push(event); + }); + + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "autonomous-msg", + content: { type: "text", text: "Autonomous update" }, + } as SessionUpdate, + }); + + const turnStarted = events.find((e) => e.type === "turn_started"); + expect(turnStarted).toBeDefined(); + const autonomousTurnId = (turnStarted as { turnId?: string }).turnId; + + // Starting a foreground turn should complete the autonomous turn first. + void session.startTurn("user prompt"); + await vi.runOnlyPendingTimersAsync(); + + const turnCompleted = events.find( + (e) => e.type === "turn_completed" && (e as { turnId?: string }).turnId === autonomousTurnId, + ); + expect(turnCompleted).toBeDefined(); + + vi.useRealTimers(); + }); + test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => { const session = createSession(); const events: Array<{ type: string; turnId?: string }> = []; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 6da37dad6..51e24c669 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1331,6 +1331,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly extensionCommandsParser?: ACPExtensionCommandsParser; private currentTurnUsage: AgentUsage | undefined; private activeForegroundTurnId: string | null = null; + private autonomousTurnId: string | null = null; + private autonomousTurnTimer: ReturnType | null = null; + private static readonly AUTONOMOUS_TURN_TIMEOUT_MS = 30_000; private fallbackAssistantMessageId: string | null = null; private closed = false; private historyPending = false; @@ -1472,6 +1475,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (this.activeForegroundTurnId) { throw new Error("A foreground turn is already active"); } + this.completeAutonomousTurn(); const turnId = randomUUID(); const messageId = options?.messageId ?? randomUUID(); @@ -2115,7 +2119,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { agentId: this.agentId, provider: this.provider, sessionId: this.sessionId, - turnId: this.activeForegroundTurnId ?? undefined, + turnId: this.activeForegroundTurnId ?? this.autonomousTurnId ?? undefined, rawEvent: params, events, }, @@ -2130,6 +2134,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + if (events.length > 0 && !this.activeForegroundTurnId) { + this.startAutonomousTurn(); + this.resetAutonomousTurnTimer(); + } + for (const event of events) { this.pushEvent(event); } @@ -2663,23 +2672,25 @@ export class ACPAgentSession implements AgentSession, ACPClient { type: "timeline", provider: this.provider, item, - turnId: this.activeForegroundTurnId ?? undefined, + turnId: this.activeForegroundTurnId ?? this.autonomousTurnId ?? undefined, }; } private pushEvent(event: AgentStreamEvent): void { + const turnId = this.activeForegroundTurnId ?? this.autonomousTurnId; + const tagged = event.type === "timeline" && turnId ? { ...event, turnId } : event; this.logger.trace( { agentId: this.agentId, provider: this.provider, sessionId: this.sessionId, - turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined, - event, + turnId: getAgentStreamEventTurnId(tagged) ?? turnId ?? undefined, + event: tagged, }, "provider.acp.event_emit", ); for (const subscriber of this.subscribers) { - subscriber(event); + subscriber(tagged); } } @@ -2727,6 +2738,41 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.pushEvent(event); } + private startAutonomousTurn(): void { + if (this.autonomousTurnId) { + return; + } + this.autonomousTurnId = randomUUID(); + this.pushEvent({ + type: "turn_started", + provider: this.provider, + turnId: this.autonomousTurnId, + }); + } + + private completeAutonomousTurn(): void { + if (!this.autonomousTurnId) { + return; + } + if (this.autonomousTurnTimer) { + clearTimeout(this.autonomousTurnTimer); + this.autonomousTurnTimer = null; + } + const turnId = this.autonomousTurnId; + this.autonomousTurnId = null; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + } + + private resetAutonomousTurnTimer(): void { + if (this.autonomousTurnTimer) { + clearTimeout(this.autonomousTurnTimer); + } + this.autonomousTurnTimer = setTimeout(() => { + this.completeAutonomousTurn(); + }, ACPAgentSession.AUTONOMOUS_TURN_TIMEOUT_MS); + this.autonomousTurnTimer.unref?.(); + } + private isSubmittedUserMessageEcho( item: Extract, ): boolean { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 60001add3..99a6acfe2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5036,6 +5036,7 @@ function readClaudeSidechainHistory(historyPath: string): string[] { } interface ClaudeHistoricalSubagentToolCall { + name?: string; subagentType?: string; description?: string; } @@ -5057,9 +5058,11 @@ function readClaudeHistoricalSubagentToolCalls( continue; } const input = toObjectRecord(block.input); + const name = readNonEmptyString(input?.name); const subagentType = readNonEmptyString(input?.subagent_type); const description = readNonEmptyString(input?.description); toolCalls.set(block.id, { + ...(name ? { name } : {}), ...(subagentType ? { subagentType } : {}), ...(description ? { description } : {}), }); @@ -5121,6 +5124,12 @@ function buildClaudePersistedSidechainEvents( return events; } +function resolveClaudeHistoricalSubagentTitle( + toolCall: ClaudeHistoricalSubagentToolCall | undefined, +): string { + return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent"; +} + function buildClaudePersistedSidechainAgentEvents( agentId: string, entries: ClaudeHistoryEntry[], @@ -5139,7 +5148,7 @@ function buildClaudePersistedSidechainAgentEvents( event: { type: "upsert", id, - title: toolCall?.subagentType ?? "Claude subagent", + title: resolveClaudeHistoricalSubagentTitle(toolCall), description: toolCall?.description ?? null, status: "running", toolCallId: result?.toolCallId ?? null, diff --git a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index a9d852a45..6dab8d205 100644 --- a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts @@ -139,7 +139,11 @@ describe("ClaudeAgentSession history replay regression", () => { type: "tool_use", id: "history-task-call", name: "Agent", - input: { description: "Inspect persisted history" }, + input: { + name: "history_researcher", + subagent_type: "Explore", + description: "Inspect persisted history", + }, }, ], }, @@ -303,6 +307,16 @@ describe("ClaudeAgentSession history replay regression", () => { timestamp: "2026-07-12T10:00:01.000Z", }, }); + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: expect.objectContaining({ + type: "upsert", + id: "history-task-call", + title: "history_researcher", + status: "running", + }), + }); expect(historyEvents).toContainEqual({ type: "provider_subagent", provider: "claude", diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts new file mode 100644 index 000000000..615ca81f3 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; + +describe("ClaudeSidechainTracker", () => { + it("uses Claude's native agent name for the provider subagent title", () => { + const tracker = new ClaudeSidechainTracker({ + getToolInput: () => ({ + name: "repo_researcher", + subagent_type: "Explore", + description: "Inspect the repository", + }), + }); + + const events = tracker.handleMessage( + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { content: [] }, + } as unknown as SDKMessage, + "task-1", + ); + + expect(events[0]).toEqual({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id: "task-1", + title: "repo_researcher", + description: "Inspect the repository", + status: "running", + toolCallId: "task-1", + }, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index aef9e91b6..d5257e3d1 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -22,6 +22,7 @@ interface SubAgentActionEntry { } interface SubAgentActivityState { + name?: string; subAgentType?: string; description?: string; actions: SubAgentActionEntry[]; @@ -130,7 +131,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id: parentToolUseId, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status: "running", toolCallId: parentToolUseId, @@ -163,7 +164,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -185,7 +186,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -267,10 +268,15 @@ export class ClaudeSidechainTracker { parentToolUseId: string, ): boolean { const taskInput = this.getToolInput(parentToolUseId); + const nextName = this.normalizeSubAgentText(taskInput?.name); const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type); const nextDescription = this.normalizeSubAgentText(taskInput?.description); let changed = false; + if (nextName && nextName !== state.name) { + state.name = nextName; + changed = true; + } if (nextSubAgentType && nextSubAgentType !== state.subAgentType) { state.subAgentType = nextSubAgentType; changed = true; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8f77da542..569370cae 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -32,6 +32,7 @@ import { type FakeCodexAppServer, waitForNextPermission, waitForNextTimelineItem, + waitForProviderSubagent, waitForTimelineToolCall, } from "./codex/test-utils/fake-app-server.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; @@ -1846,6 +1847,60 @@ describe("Codex app-server provider", () => { }); }); + test("updates a registered child with its later native activity name", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "call-native-name-later", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the repository.", + receiverThreadIds: ["child-native-name-later"], + agentsStates: { + "child-native-name-later": { status: "pendingInit", message: null }, + }, + }, + }); + asInternals(session).handleNotification("item/started", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "activity-native-name-later", + kind: "started", + agentThreadId: "child-native-name-later", + agentPath: "/root/research/investigator", + }, + }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + provider: "codex", + event: expect.objectContaining({ + type: "upsert", + id: "child-native-name-later", + title: "Research / Investigator", + }), + }), + ); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "call-native-name-later", + detail: { + type: "sub_agent", + subAgentType: "Research / Investigator", + description: "Inspect the repository.", + }, + }, + }); + }); + test("renders child MCP image results in the provider subagent timeline", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -2457,6 +2512,158 @@ describe("Codex app-server provider", () => { expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); }); + test("discovers a MultiAgentV2 child from a legacy-only lifecycle notification", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Delegate the investigation."); + await appServer.waitForTurnStart(); + const child = waitForProviderSubagent(session, "legacy-only-child-thread"); + const spawn = waitForTimelineToolCall(session, "spawn-legacy-only-child"); + + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-with-legacy-only-child" }); + appServer.startsLegacyOnlySubAgent({ + callId: "spawn-legacy-only-child", + threadId: "legacy-only-child-thread", + agentPath: "/root/legacy-only-child", + }); + + await expect(child).resolves.toMatchObject({ + type: "provider_subagent", + provider: "codex", + turnId: "codex-turn-0", + event: { + type: "upsert", + id: "legacy-only-child-thread", + status: "running", + }, + }); + await expect(spawn).resolves.toMatchObject({ + type: "timeline", + provider: "codex", + turnId: "codex-turn-0", + item: { + type: "tool_call", + callId: "spawn-legacy-only-child", + status: "running", + detail: { + type: "sub_agent", + description: "legacy-only-child", + }, + }, + }); + + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("reports when Codex rejects a foreground turn interrupt", async () => { + const appServer = createFakeCodexAppServer({ + "turn/interrupt": async () => { + throw new Error("A foreground turn is already active"); + }, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Wait for the child."); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-waiting-for-child" }); + + await expect(session.interrupt()).rejects.toThrow("A foreground turn is already active"); + + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("rejects an interrupt until Codex identifies the accepted turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Start working."); + await appServer.waitForTurnStart(); + + await expect(session.interrupt()).rejects.toThrow( + "Cannot interrupt Codex before turn/started identifies the active turn", + ); + + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" }); + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("rejects an interrupt before Codex initializes the thread", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + await expect(session.interrupt()).rejects.toThrow( + "Cannot interrupt Codex before the active thread is initialized", + ); + + await session.close(); + }); + + test("interrupts an autonomous Codex turn identified by live notifications", async () => { + const session = createSession(); + const requests: Array<{ method: string; params: unknown }> = []; + session.activeForegroundTurnId = null; + session.client = { + request: async (method, params) => { + requests.push({ method, params }); + return {}; + }, + }; + + asInternals(session).handleNotification("turn/started", { + threadId: "test-thread", + turn: { id: "autonomous-turn" }, + }); + + await session.interrupt(); + + expect(requests).toContainEqual({ + method: "turn/interrupt", + params: { + threadId: "test-thread", + turnId: "autonomous-turn", + }, + }); + }); + test("never replaces the root identity with an early child thread start", () => { const session = createSession(); @@ -2726,6 +2933,13 @@ describe("Codex app-server provider", () => { receiverThreadIds: ["legacy-child-thread"], agentsStates: { "legacy-child-thread": { status: "completed" } }, }, + { + type: "subAgentActivity", + id: "legacy-native-name-history", + kind: "started", + agentThreadId: "legacy-child-thread", + agentPath: "/root/sentinel_child", + }, { type: "subAgentActivity", id: "v2-spawn-history", @@ -2752,7 +2966,12 @@ describe("Codex app-server provider", () => { event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], ), ).toMatchObject([ - { type: "upsert", id: "legacy-child-thread", status: "completed" }, + { + type: "upsert", + id: "legacy-child-thread", + status: "completed", + title: "Sentinel child", + }, { type: "upsert", id: "v2-child-thread", status: "completed" }, ]); expect( @@ -2787,12 +3006,16 @@ describe("Codex app-server provider", () => { { callId: "legacy-spawn-history", status: "completed", - detail: { type: "sub_agent", description: "Legacy child" }, + detail: { + type: "sub_agent", + description: "Legacy child", + subAgentType: "Sentinel child", + }, }, { callId: "v2-spawn-history", status: "completed", - detail: { type: "sub_agent", description: "/root/v2-child" }, + detail: { type: "sub_agent", description: "v2-child" }, }, ]); @@ -2914,7 +3137,7 @@ describe("Codex app-server provider", () => { status: "canceled", detail: expect.objectContaining({ type: "sub_agent", - description: "/root/history-child", + description: "history-child", }), }), }, @@ -3515,7 +3738,7 @@ describe("Codex app-server provider", () => { }); }); - test("emits imageView thread items as assistant markdown images using the path", () => { + test("emits imageView paths with spaces as valid assistant markdown images", () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -3535,15 +3758,15 @@ describe("Codex app-server provider", () => { turnId: "test-turn", item: { type: "assistant_message", - text: "![Image](/tmp/paseo image.png)", + text: "![Image](file:///tmp/paseo%20image.png)", }, }, ]); }); test.each([ - ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "/tmp/generated-camel.png"], - ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "/tmp/generated-snake.png"], + ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "file:///tmp/generated-camel.png"], + ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"], ])( "emits imageGeneration thread items with %s as assistant markdown images", (_fieldName, imageFields, expectedPath) => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index dc77fdb7e..fd6b7b68e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1647,6 +1647,10 @@ function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null }; } +function shouldIgnoreMirroredLifecycleItem(source: "item" | "codex_event", item: unknown): boolean { + return source === "codex_event" && !readCodexSubAgentActivity(item); +} + function settleHistoricalSubAgentActivity( item: ToolCallTimelineItem, kind: CodexSubAgentActivity["kind"], @@ -1664,14 +1668,22 @@ function updateHistoricalSubAgentActivity( timeline: PersistedTimelineEntry[], index: number, kind: CodexSubAgentActivity["kind"], + subAgentType?: string, ): void { const existing = timeline[index]; if (existing?.item.type !== "tool_call") { return; } + const settledItem = settleHistoricalSubAgentActivity(existing.item, kind); timeline[index] = { ...existing, - item: settleHistoricalSubAgentActivity(existing.item, kind), + item: + subAgentType && settledItem.detail.type === "sub_agent" + ? { + ...settledItem, + detail: { ...settledItem.detail, subAgentType }, + } + : settledItem, }; } @@ -1865,10 +1877,15 @@ async function loadCodexThreadHistoryTimeline(params: { historicalSubAgentActivity.agentThreadId, ); if (existingIndex !== undefined) { + const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd }); updateHistoricalSubAgentActivity( timeline, existingIndex, historicalSubAgentActivity.kind, + activityTimelineItem?.type === "tool_call" && + activityTimelineItem.detail.type === "sub_agent" + ? activityTimelineItem.detail.subAgentType + : undefined, ); continue; } @@ -3800,6 +3817,7 @@ export class CodexAppServerAgentSession implements AgentSession { const turnId = this.createTurnId(); this.activeForegroundTurnId = turnId; + this.currentTurnId = null; try { this.logTurnStartSummary({ @@ -4191,19 +4209,20 @@ export class CodexAppServerAgentSession implements AgentSession { } async interrupt(): Promise { - if (!this.client || !this.currentThreadId || !this.currentTurnId) return; - try { - await this.client.request( - "turn/interrupt", - { - threadId: this.currentThreadId, - turnId: this.currentTurnId, - }, - INTERRUPT_TIMEOUT_MS, - ); - } catch (error) { - this.logger.warn({ error }, "Failed to interrupt Codex turn"); + if (!this.client || !this.currentThreadId) { + throw new Error("Cannot interrupt Codex before the active thread is initialized"); } + if (!this.currentTurnId) { + throw new Error("Cannot interrupt Codex before turn/started identifies the active turn"); + } + await this.client.request( + "turn/interrupt", + { + threadId: this.currentThreadId, + turnId: this.currentTurnId, + }, + INTERRUPT_TIMEOUT_MS, + ); } async close(): Promise { @@ -4837,6 +4856,21 @@ export class CodexAppServerAgentSession implements AgentSession { if (activity.id) { state.activityItemIds.add(activity.id); } + const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, { + cwd: this.config.cwd ?? null, + }); + if ( + activityToolCall?.detail.type === "sub_agent" && + state.toolCall.detail.type === "sub_agent" + ) { + state.toolCall = { + ...state.toolCall, + detail: { + ...state.toolCall.detail, + subAgentType: activityToolCall.detail.subAgentType, + }, + }; + } this.emitSubAgentActivityUpdate( callId, activity.kind === "interrupted" ? "canceled" : "running", @@ -5528,9 +5562,10 @@ export class CodexAppServerAgentSession implements AgentSession { parsed: Extract, ): void { // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` - // and canonical `item/*`. We render only the canonical channel to avoid - // duplicated assistant/reasoning rows. - if (parsed.source === "codex_event") { + // and canonical `item/*`. Render ordinary items only from the canonical + // channel, but accept a legacy-only child announcement so it can establish + // the provider-subagent route. + if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } if (this.isUserMessageItem(parsed.item)) { @@ -5684,7 +5719,7 @@ export class CodexAppServerAgentSession implements AgentSession { private handleItemStartedNotification( parsed: Extract, ): void { - if (parsed.source === "codex_event") { + if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } if (this.isUserMessageItem(parsed.item)) { diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 9ef0c354e..0e7488e9a 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -58,6 +58,12 @@ export interface FakeCodexAppServer { agentPath: string; parentThreadId?: string; }): void; + startsLegacyOnlySubAgent(params: { + callId: string; + threadId: string; + agentPath: string; + parentThreadId?: string; + }): void; beginsSubAgentActivity(params: FakeSubAgentActivity): void; completesSubAgentActivity(params: FakeSubAgentActivity): void; completesCompaction(params: { threadId: string; itemId: string }): void; @@ -324,6 +330,18 @@ export function createFakeCodexAppServer( startsSubAgent(params) { writeSubAgentActivity("item/completed", { ...params, kind: "started" }); }, + startsLegacyOnlySubAgent(params) { + writeLegacyEvent(params.parentThreadId ?? "thread-1", "codex/event/item_completed", { + type: "item_completed", + item: { + type: "subAgentActivity", + id: params.callId, + kind: "started", + agentThreadId: params.threadId, + agentPath: params.agentPath, + }, + }); + }, beginsSubAgentActivity(params) { writeSubAgentActivity("item/started", params); }, @@ -534,6 +552,7 @@ function waitForNextEvent( } type TimelineEvent = StreamEventOfType<"timeline">; +type ProviderSubagentEvent = StreamEventOfType<"provider_subagent">; export function waitForNextPermission( session: AgentSession, @@ -555,3 +574,10 @@ export function waitForTimelineToolCall( (event) => event.item.type === "tool_call" && event.item.callId === callId, ); } + +export function waitForProviderSubagent( + session: AgentSession, + id: string, +): Promise { + return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id); +} diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 6c8919e52..f9df7ee62 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -234,7 +234,7 @@ describe("codex tool-call mapper", () => { id: `activity-${kind}`, kind, agentThreadId: "child-thread-1", - agentPath: "/root/investigator", + agentPath: "/root/research/investigator", }); expect(item).toEqual({ @@ -245,8 +245,8 @@ describe("codex tool-call mapper", () => { error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", - description: "/root/investigator", + subAgentType: "Research / Investigator", + description: "research/investigator", log: "", actions: [], }, @@ -267,6 +267,60 @@ describe("codex tool-call mapper", () => { }); }); + it("humanizes a subagent task name for display", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-human-name", + kind: "started", + agentThreadId: "child-thread-human-name", + agentPath: "/root/hello_one", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Hello one", + description: "hello_one", + }, + }); + }); + + it("uses only the final segment of a subAgentActivity path outside the root namespace", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-external-path", + kind: "started", + agentThreadId: "child-thread-external-path", + agentPath: "/tmp/native/investigator", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Investigator", + description: "investigator", + }, + }); + }); + + it("uses only the final segment of a Windows subAgentActivity path", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-windows-path", + kind: "started", + agentThreadId: "child-thread-windows-path", + agentPath: "C:\\Users\\dev\\agents\\investigator", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Investigator", + description: "investigator", + }, + }); + }); + it("does not fail a collabAgentToolCall from child error state alone", () => { const item = mapCodexToolCallFromThreadItem({ type: "collabAgentToolCall", diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 967b6b43a..b3775e42c 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -993,6 +993,24 @@ function mapCollabAgentToolCallItem( function mapSubAgentActivityItem( item: z.infer, ): ToolCallTimelineItem { + let nativeName = item.agentPath; + if (nativeName === "/root") { + nativeName = ""; + } else if (nativeName.startsWith("/root/")) { + nativeName = nativeName.slice("/root/".length); + } else if (/[\\/]/.test(nativeName)) { + nativeName = nativeName.slice( + Math.max(nativeName.lastIndexOf("/"), nativeName.lastIndexOf("\\")) + 1, + ); + } + const description = nativeName; + nativeName = nativeName + .split("/") + .map((segment) => segment.replace(/[_-]+/g, " ").trim()) + .filter(Boolean) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(" / "); + nativeName ||= "Sub-agent"; return { type: "tool_call", callId: item.id, @@ -1001,8 +1019,8 @@ function mapSubAgentActivityItem( error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", - description: item.agentPath, + subAgentType: nativeName, + description, log: "", actions: [], }, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 51763d336..98d904076 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -179,6 +179,38 @@ describe("MockLoadTestAgentClient", () => { expect(events).toHaveLength(eventCountAfterInterrupt); }); + test("emits a terminal failure without an assistant provider message", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + await session.startTurn("Emit a synthetic turn failure."); + await vi.advanceTimersByTimeAsync(0); + unsubscribe(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "user_message" }), + }), + ); + expect( + events.filter( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ), + ).toHaveLength(0); + expect(events.at(-1)).toMatchObject({ + type: "turn_failed", + error: "Requested mock provider failure", + }); + }); + test("emits the free-write question scenario selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 1609fe4a2..d371ef1ac 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -152,6 +152,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); } +function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean { + return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt)); +} + function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { const text = promptToText(prompt); if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { @@ -652,7 +656,9 @@ export class MockLoadTestAgentSession implements AgentSession { const stress = parseAgentStreamStressPrompt(prompt); const questionPrompt = parseMockQuestionPrompt(prompt); const structuredBranchName = parseStructuredBranchNamePrompt(prompt); - if (structuredBranchName) { + if (shouldEmitTurnFailure(prompt)) { + this.scheduleFailedTurn(turn); + } else if (structuredBranchName) { this.scheduleStructuredJsonTurn(turn, structuredBranchName); } else if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); @@ -816,6 +822,34 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleFailedTurn(turn: ActiveTurn): void { + turn.timer = setTimeout(() => { + if (this.activeTurn !== turn) { + return; + } + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + this.activeTurn = null; + this.emit({ + type: "turn_failed", + provider: this.provider, + turnId: turn.turnId, + error: "Requested mock provider failure", + }); + turn.resolve({ + sessionId: this.id, + finalText: "", + timeline: [], + canceled: false, + }); + }, 0); + turn.timer.unref?.(); + } + private scheduleStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { turn.timer = setTimeout(() => { this.emitStressTurn(turn, stress); diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 9ebbe00fe..467e68bc4 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; import { describe, expect, onTestFinished, test } from "vitest"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; @@ -54,6 +55,9 @@ function readUtf8File(pathname: string): string { closeSync(fd); } } +async function flushTurnScheduling(): Promise { + await waitForImmediate(); +} async function createSession(pi = new FakePi()): Promise<{ pi: FakePi; @@ -147,6 +151,13 @@ class SessionEvents { }); } + turnCompletedEvents() { + return this.events.filter( + (event): event is Extract => + event.type === "turn_completed", + ); + } + nextTurnCompletion(): Promise> { return this.nextEvent( (event): event is Extract => @@ -885,6 +896,137 @@ describe("PiRpcAgentSession", () => { error: "Pi exited", }); }); + test("completes locally handled slash commands when agentInvoked is false", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.promptAck = { agentInvoked: false }; + + const { turnId: usageTurnId } = await session.startTurn("/usage"); + fakeSession.emit({ + type: "command_output", + text: "\u001b[38;2;138;138;138mUsage 12%\u001b[39m", + }); + + await flushTurnScheduling(); + const usageCompletion = await events.nextTurnCompletion(); + expect(usageCompletion).toMatchObject({ type: "turn_completed", turnId: usageTurnId }); + expect(events.timelineAndCompletionEvents()).toEqual([ + { type: "timeline", item: { type: "user_message", text: "/usage" } }, + { type: "timeline", item: { type: "assistant_message", text: "Usage 12%" } }, + { type: "turn_completed" }, + ]); + + const { turnId: helloTurnId } = await session.startTurn("hello"); + fakeSession.finishTurn(); + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(2); + expect(events.turnCompletedEvents()[1]).toMatchObject({ + type: "turn_completed", + turnId: helloTurnId, + }); + }); + + test("does not synthesize completion when agentInvoked is true for slash prompts", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.promptAck = { agentInvoked: true }; + + const { turnId } = await session.startTurn("/usage"); + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(0); + + fakeSession.emit({ type: "agent_start" }); + fakeSession.finishTurn(); + await flushTurnScheduling(); + + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.turnCompletedEvents()).toHaveLength(1); + }); + + test("probes slash prompts without agentInvoked and surfaces buffered notify output", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + const { turnId } = await session.startTurn("/plan on"); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-plan", + method: "notify", + message: "Plan mode enabled", + }); + + await flushTurnScheduling(); + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.timelineAndCompletionEvents()).toEqual([ + { type: "timeline", item: { type: "user_message", text: "/plan on" } }, + { type: "timeline", item: { type: "assistant_message", text: "Plan mode enabled" } }, + { type: "turn_completed" }, + ]); + }); + + test("does not synthesize completion when lifecycle starts before the no-turn probe", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + const { turnId } = await session.startTurn("/custom-template-cmd"); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-buffered", + method: "notify", + message: "Should not appear after turn starts", + }); + fakeSession.emit({ type: "agent_start" }); + + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(0); + expect(events.timelineItems()).toEqual([]); + + fakeSession.finishTurn(); + await flushTurnScheduling(); + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.turnCompletedEvents()).toHaveLength(1); + }); + + test("fails slash turns when the no-turn getState barrier errors", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.getStateError = new Error("get_state timed out"); + + const { turnId } = await session.startTurn("/local-command on"); + await flushTurnScheduling(); + + await expect(events.nextTurnFailure()).resolves.toMatchObject({ + turnId, + error: "get_state timed out", + }); + + fakeSession.getStateError = null; + const { turnId: recoveryTurnId } = await session.startTurn("hello"); + fakeSession.finishTurn(); + await flushTurnScheduling(); + await expect(events.nextTurnCompletion()).resolves.toMatchObject({ + type: "turn_completed", + turnId: recoveryTurnId, + }); + }); + + test("does not probe non-slash prompts when agentInvoked is missing", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + const { turnId } = await session.startTurn("hello"); + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(0); + + fakeSession.finishTurn(); + await flushTurnScheduling(); + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.turnCompletedEvents()).toHaveLength(1); + }); }); describe("PiRpcAgentClient", () => { diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index e57126e7f..8cbdfa420 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { homedir, tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; +import stripAnsi from "strip-ansi"; import { z } from "zod"; import { @@ -228,6 +229,12 @@ interface PendingPiUserMessage { turnId: string | undefined; } +interface PendingLocalPrompt { + turnId: string; + text: string; + outputs: string[]; +} + interface PendingExtensionResult { resolve: (value: unknown) => void; reject: (error: Error) => void; @@ -1064,6 +1071,7 @@ export class PiRpcAgentSession implements AgentSession { private activeAskUserDialog: ActiveAskUserDialog | null = null; private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; private activeTurnId: string | null = null; + private pendingLocalPrompt: PendingLocalPrompt | null = null; private activeAssistantMessageId: string | null = null; private lastKnownThinkingOptionId: string | null; currentLeafOverrideId: string | null | undefined; @@ -1124,29 +1132,43 @@ export class PiRpcAgentSession implements AgentSession { const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.activeTurnId = turnId; + this.pendingLocalPrompt = { turnId, text: payload.text, outputs: [] }; this.activeAssistantMessageId = null; + const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null; - void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => { - if (this.activeTurnId !== turnId) { - return; - } - this.activeTurnId = null; - if (isPiRequestAbortError(error)) { + void (async () => { + try { + const ack = await this.runtimeSession.prompt(payload.text, payload.images); + if (ack.agentInvoked === false) { + await this.completeNoTurnPrompt(turnId); + return; + } + if (ack.agentInvoked === undefined && shouldProbeForNoTurnPrompt) { + await this.completePromptIfHandledWithoutTurn(turnId); + } + } catch (error) { + if (this.activeTurnId !== turnId) { + return; + } + this.activeTurnId = null; + this.pendingLocalPrompt = null; + if (isPiRequestAbortError(error)) { + this.emit({ + type: "turn_canceled", + provider: PI_PROVIDER, + turnId, + reason: toDiagnosticErrorMessage(error), + }); + return; + } this.emit({ - type: "turn_canceled", + type: "turn_failed", provider: PI_PROVIDER, turnId, - reason: toDiagnosticErrorMessage(error), + error: toDiagnosticErrorMessage(error), }); - return; } - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId, - error: toDiagnosticErrorMessage(error), - }); - }); + })(); return { turnId }; } @@ -1242,6 +1264,7 @@ export class PiRpcAgentSession implements AgentSession { await this.runtimeSession.abort(); if (turnId && this.activeTurnId === turnId) { this.activeTurnId = null; + this.pendingLocalPrompt = null; this.emit({ type: "turn_canceled", provider: PI_PROVIDER, reason: "interrupted", turnId }); } } @@ -1371,6 +1394,82 @@ export class PiRpcAgentSession implements AgentSession { return this.activeTurnId ?? undefined; } + private async completeNoTurnPrompt(turnId: string): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); + if (this.activeTurnId !== turnId || this.pendingLocalPrompt?.turnId !== turnId) { + return; + } + this.emitPendingLocalPrompt(); + this.completeTurn(turnId, []); + } + + private async completePromptIfHandledWithoutTurn(turnId: string): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); + + let runtimeState: PiSessionState; + try { + runtimeState = await this.runtimeSession.getState(); + } catch (error) { + if (this.activeTurnId === turnId && this.pendingLocalPrompt?.turnId === turnId) { + throw error; + } + return; + } + + if ( + this.activeTurnId !== turnId || + this.pendingLocalPrompt?.turnId !== turnId || + runtimeState.isStreaming + ) { + return; + } + this.state = runtimeState; + + this.emitPendingLocalPrompt(); + this.completeTurn(turnId, []); + } + + private emitPendingLocalPrompt(): void { + const prompt = this.pendingLocalPrompt; + this.pendingLocalPrompt = null; + if (!prompt) { + return; + } + if (prompt.text) { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId: prompt.turnId, + item: { + type: "user_message", + text: prompt.text, + }, + }); + } + for (const output of prompt.outputs) { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId: prompt.turnId, + item: { + type: "assistant_message", + text: output, + }, + }); + } + } + + private bufferLocalPromptOutput(message: string): void { + if (!this.pendingLocalPrompt) { + return; + } + this.pendingLocalPrompt.outputs.push(message); + } + private parseSlashCommandInput(text: string): PiSlashCommandInvocation | null { const trimmed = text.trim(); if (!trimmed.startsWith("/") || trimmed.length <= 1) { @@ -1613,6 +1712,7 @@ export class PiRpcAgentSession implements AgentSession { if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) { return; } + this.bufferLocalPromptOutput(message); } if (this.respondToCombinedAskUserFollowUp(event)) { @@ -1667,6 +1767,26 @@ export class PiRpcAgentSession implements AgentSession { return false; } + private handleCommandOutput(textValue: unknown): void { + if (!this.activeTurnId) { + return; + } + const text = stripAnsi(optionalString(textValue) ?? "").trim(); + if (!text) { + return; + } + if (this.pendingLocalPrompt) { + this.bufferLocalPromptOutput(text); + return; + } + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId: this.currentTurnIdForEvent(), + item: { type: "assistant_message", text }, + }); + } + private handleRuntimeEvent(event: PiRuntimeEvent): void { if (event.type === "extension_ui_request") { this.handleExtensionUiRequest(event); @@ -1676,6 +1796,10 @@ export class PiRpcAgentSession implements AgentSession { this.handleProcessExit(event.error); return; } + if (event.type === "command_output") { + this.handleCommandOutput(event.text); + return; + } this.handleSessionEvent(event); } @@ -1686,6 +1810,7 @@ export class PiRpcAgentSession implements AgentSession { } const turnId = this.activeTurnId; this.activeTurnId = null; + this.pendingLocalPrompt = null; this.emit({ type: "turn_failed", provider: PI_PROVIDER, @@ -1699,6 +1824,7 @@ export class PiRpcAgentSession implements AgentSession { switch (event.type) { case "agent_start": + this.pendingLocalPrompt = null; this.emit({ type: "thread_started", provider: PI_PROVIDER, @@ -1706,6 +1832,7 @@ export class PiRpcAgentSession implements AgentSession { }); return; case "turn_start": + this.pendingLocalPrompt = null; this.emit({ type: "turn_started", provider: PI_PROVIDER, @@ -1922,6 +2049,7 @@ export class PiRpcAgentSession implements AgentSession { private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { this.activeTurnId = null; + this.pendingLocalPrompt = null; this.activeAssistantMessageId = null; const errorMessage = latestPiErrorMessage(messages); if (typeof errorMessage === "string" && errorMessage.length > 0) { diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 001c6339c..4b7418695 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -15,6 +15,7 @@ import type { PiAgentMessage, PiCommandsRpcType, PiModel, + PiPromptAck, PiRpcCommand, PiRpcResponse, PiRpcSlashCommand, @@ -136,8 +137,19 @@ class PiCliRuntimeSession implements PiRuntimeSession { async prompt( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, - ): Promise { - await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) }); + ): Promise { + const data = await this.request({ + type: "prompt", + message, + ...(images?.length ? { images } : {}), + }); + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + const { agentInvoked } = data as Record; + if (typeof agentInvoked === "boolean") { + return { agentInvoked }; + } + } + return {}; } async compact(customInstructions?: string): Promise { diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts index a13fa9708..6b0cc6c76 100644 --- a/packages/server/src/server/agent/providers/pi/rpc-types.ts +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -5,6 +5,9 @@ export interface PiImageContent { data: string; mimeType: string; } +export interface PiPromptAck { + agentInvoked?: boolean; +} export interface PiTextContent { type: "text"; @@ -181,6 +184,10 @@ export type PiRuntimeEvent = method: string; [key: string]: unknown; } + | { + type: "command_output"; + text?: string; + } | { type: "process_exit"; error: string; diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index e3d4ca72b..a4e4be58a 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -1,6 +1,7 @@ import type { PiAgentMessage, PiModel, + PiPromptAck, PiRpcSlashCommand, PiRuntimeEvent, PiSessionState, @@ -38,7 +39,7 @@ export interface PiRuntimeSession { prompt( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, - ): Promise; + ): Promise; compact(customInstructions?: string): Promise; setAutoCompaction(enabled: boolean): Promise; abort(): Promise; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 2fc4072e1..d27b44f66 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -7,6 +7,7 @@ import type { import type { PiAgentMessage, PiModel, + PiPromptAck, PiRpcSlashCommand, PiRuntimeEvent, PiSessionState, @@ -73,6 +74,8 @@ export class FakePiSession implements PiRuntimeSession { commands: PiRpcSlashCommand[] = []; compactError: Error | null = null; emitCompactEnd = true; + getStateError: Error | null = null; + promptAck: PiPromptAck = {}; state: PiSessionState; private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>(); @@ -104,7 +107,7 @@ export class FakePiSession implements PiRuntimeSession { async prompt( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, - ): Promise { + ): Promise { this.prompts.push({ message, imageCount: images?.length ?? 0 }); const heldPrompt = this.nextHeldPrompt; if (heldPrompt) { @@ -120,6 +123,7 @@ export class FakePiSession implements PiRuntimeSession { } this.handleTreeNavigationCommand(message); this.handleEntryCaptureCommand(message); + return this.promptAck; } holdNextPrompt(): void { @@ -166,6 +170,9 @@ export class FakePiSession implements PiRuntimeSession { } async getState(): Promise { + if (this.getStateError) { + throw this.getStateError; + } return this.state; } diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index 49ffcf94b..0c32af04f 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -49,6 +49,37 @@ describe("isProviderImageMarkdown", () => { expect(isProviderImageMarkdown(markdown)).toBe(true); }); + test("emits POSIX file paths with spaces as valid file URI markdown", () => { + const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png"); + + expect(markdown).toBe( + "![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)", + ); + }); + + test("encodes URI-significant characters in POSIX file paths", () => { + const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png"); + + expect(markdown).toBe("![Image](file:///tmp/screenshot%231%3Fdraft.png)"); + }); + + test("preserves double-leading slashes in POSIX file paths", () => { + const markdown = renderImageMarkdown("//tmp/screenshot#1.png"); + + expect(markdown).toBe("![Image](file:////tmp/screenshot%231.png)"); + }); + + test.each([ + ["UNC", "\\\\server\\share\\shot#1.png", "file://server/share/shot%231.png"], + [ + "extended UNC", + "\\\\?\\UNC\\server\\share\\shot?draft.png", + "file://server/share/shot%3Fdraft.png", + ], + ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => { + expect(renderImageMarkdown(imagePath)).toBe(`![Image](${expectedSource})`); + }); + test("rejects user-authored markdown that is not a materialized attachment", () => { // No content hash — a hand-written path, not something the writer produced. expect(isProviderImageMarkdown("![diagram](./paseo-attachments/notes.png)")).toBe(false); diff --git a/packages/server/src/server/agent/providers/provider-image-output.ts b/packages/server/src/server/agent/providers/provider-image-output.ts index 649e5bdfe..387158b32 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.ts @@ -128,9 +128,39 @@ function escapeMarkdownImageAlt(value: string): string { return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]"); } +function encodeFilePath(value: string): string { + return value + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function windowsFileUri(value: string): string | null { + const isWindowsNetworkPath = value.startsWith("\\\\"); + let normalizedPath = value.replace(/\\/g, "/"); + if (/^\/\/\?\/UNC\//i.test(normalizedPath)) { + normalizedPath = `//${normalizedPath.slice(8)}`; + } else if (/^\/\/\?\/[A-Za-z]:\//.test(normalizedPath)) { + normalizedPath = normalizedPath.slice(4); + } + + if (/^[A-Za-z]:\//.test(normalizedPath)) { + const drive = normalizedPath.slice(0, 2); + return `file:///${drive}${encodeFilePath(normalizedPath.slice(2))}`; + } + if (isWindowsNetworkPath && normalizedPath.startsWith("//")) { + return `file:${encodeFilePath(normalizedPath)}`; + } + return null; +} + function markdownImageSource(value: string): string { - if (/^[A-Za-z]:[\\/]/.test(value)) { - return `file:///${value.replace(/\\/g, "/")}`; + const windowsUri = windowsFileUri(value); + if (windowsUri) { + return windowsUri; + } + if (value.startsWith("/")) { + return `file://${encodeFilePath(value)}`; } return value; } diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index 15efcb1ad..a321fb79f 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -100,6 +100,35 @@ describe("AgentManager rewind", () => { expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]); }); + test("does not rewind when the in-flight turn rejects cancellation", async () => { + class RejectingInterruptSession extends FakeRewindSession { + override async interrupt(): Promise { + throw new Error("provider still owns the active turn"); + } + } + + const session = new RejectingInterruptSession(); + const manager = new AgentManager({ + clients: { claude: new FakeRewindClient(session) }, + logger: createTestLogger(), + idFactory: () => "00000000-0000-4000-8000-000000000902", + }); + const agent = await manager.createAgent({ provider: "claude", cwd: process.cwd() }, undefined, { + workspaceId: undefined, + }); + const run = manager.streamAgent(agent.id, "keep working"); + await run.next(); + + await expect(manager.rewind(agent.id, "message-1", "files")).rejects.toThrow( + `Cannot rewind agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + expect(session.recordedRewinds).toEqual([]); + expect(manager.getAgent(agent.id)).toMatchObject({ + lifecycle: "running", + activeForegroundTurnId: "turn-1", + }); + }); + test("blocks new prompts until the rehydrate epoch broadcasts", async () => { const historyGate = new RewindHistoryGate(); historyGate.hold(); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 89f66f351..944ca4465 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -210,105 +210,120 @@ test("createAgent with background initialPrompt returns a running snapshot befor } }); +interface StubAgentOptions { + sessionId: string; + supportsStreaming: boolean; + startError?: string; + interruptError?: string; +} + +class StubAgentSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities; + private activeTurnId: string | null = null; + + constructor(private readonly options: StubAgentOptions) { + this.capabilities = { + supportsStreaming: options.supportsStreaming, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + } as const; + } + + get id(): string { + return this.options.sessionId; + } + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(): Promise<{ turnId: string }> { + if (this.options.startError) throw new Error(this.options.startError); + if (this.activeTurnId) throw new Error("A foreground turn is already active"); + this.activeTurnId = "provider-owned-turn"; + return { turnId: this.activeTurnId }; + } + + subscribe(): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: "gpt-5.4-mini", + modeId: "full-access", + }; + } + + async getAvailableModes() { + return [{ id: "full-access", label: "Full access", description: "No prompts" }]; + } + + async getCurrentMode(): Promise { + return "full-access"; + } + + async setMode(): Promise {} + getPendingPermissions() { + return []; + } + async respondToPermission(): Promise {} + describePersistence(): AgentPersistenceHandle { + return { provider: this.provider, sessionId: this.id }; + } + async interrupt(): Promise { + if (this.options.interruptError) throw new Error(this.options.interruptError); + } + async close(): Promise { + this.activeTurnId = null; + } +} + +class StubAgentClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities; + + constructor(private readonly options: StubAgentOptions) { + this.capabilities = new StubAgentSession(options).capabilities; + } + + async isAvailable(): Promise { + return true; + } + async createSession(): Promise { + return new StubAgentSession(this.options); + } + async resumeSession(): Promise { + return new StubAgentSession(this.options); + } + async fetchCatalog() { + return { + models: [{ id: "gpt-5.4-mini", label: "GPT-5.4 mini", provider: this.provider }], + modes: [{ id: "full-access", label: "Full access", description: "No prompts" }], + }; + } +} + test("createAgent fails when the initial turn cannot start", async () => { - class StartTurnFailureSession implements AgentSession { - readonly provider = "codex" as const; - readonly id = "start-turn-failure-session"; - readonly capabilities = { - supportsStreaming: false, - supportsSessionPersistence: true, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - supportsRewindConversation: false, - supportsRewindFiles: false, - supportsRewindBoth: false, - } as const; - - async run(): Promise { - return { - sessionId: this.id, - finalText: "", - timeline: [], - }; - } - - async startTurn(): Promise<{ turnId: string }> { - throw new Error("Initial turn failed to start"); - } - - subscribe(): () => void { - return () => undefined; - } - - async *streamHistory(): AsyncGenerator { - yield* []; - } - - async getRuntimeInfo() { - return { - provider: "codex" as const, - sessionId: this.id, - model: "gpt-5.4-mini", - modeId: "full-access", - }; - } - - async getAvailableModes(): Promise> { - return [{ id: "full-access", label: "Full access", description: "No prompts" }]; - } - - async getCurrentMode(): Promise { - return "full-access"; - } - - async setMode(): Promise {} - - getPendingPermissions() { - return []; - } - - async respondToPermission(): Promise {} - - describePersistence(): AgentPersistenceHandle | null { - return { provider: "codex", sessionId: this.id }; - } - - async interrupt(): Promise {} - - async close(): Promise {} - } - - class StartTurnFailureClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = { - supportsStreaming: false, - supportsSessionPersistence: true, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - supportsRewindConversation: false, - supportsRewindFiles: false, - supportsRewindBoth: false, - } as const; - - async isAvailable(): Promise { - return true; - } - - async createSession(_config: AgentSessionConfig): Promise { - return new StartTurnFailureSession(); - } - - async resumeSession(): Promise { - return new StartTurnFailureSession(); - } - } + const testAgent = new StubAgentClient({ + sessionId: "start-turn-failure-session", + supportsStreaming: false, + startError: "Initial turn failed to start", + }); const daemon = await createTestPaseoDaemon({ - agentClients: { codex: new StartTurnFailureClient() }, + agentClients: { codex: testAgent }, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, @@ -335,6 +350,58 @@ test("createAgent fails when the initial turn cannot start", async () => { } }); +function createUninterruptibleClient(): AgentClient { + return new StubAgentClient({ + sessionId: "uninterruptible-session", + supportsStreaming: true, + interruptError: "Provider did not acknowledge cancellation", + }); +} + +test("DaemonClient rejects a replacement prompt when cancellation is not acknowledged", async () => { + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: createUninterruptibleClient() }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + const agent = await client.createAgent({ provider: "codex", cwd }); + await client.sendMessage(agent.id, "Keep working on the first prompt."); + + await expect(client.sendMessage(agent.id, "Replace it with this prompt.")).rejects.toThrow( + `Cannot replace agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("DaemonClient rejects Stop when cancellation is not acknowledged", async () => { + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: createUninterruptibleClient() }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + const agent = await client.createAgent({ provider: "codex", cwd }); + await client.sendMessage(agent.id, "Keep working until stopped."); + + await expect(client.cancelAgent(agent.id)).rejects.toThrow( + `Cannot stop agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30_000); + function waitForSignal( timeoutMs: number, setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void, diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 67edfd173..37280535e 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -1033,6 +1033,173 @@ describe("LoopService", () => { expect(finalLoop.logs.some((entry) => entry.text.includes("Stop requested"))).toBe(true); }); + test("force-closes a loop worker when graceful cancellation is refused", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const cancelledAgentIds: string[] = []; + const closedAgentIds: string[] = []; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }), + }, + registry: storage, + logger, + }); + manager.cancelAgentRun = async (agentId) => { + cancelledAgentIds.push(agentId); + return { status: "refused" }; + }; + const closeAgent = manager.closeAgent.bind(manager); + manager.closeAgent = async (agentId) => { + closedAgentIds.push(agentId); + await closeAgent(agentId); + }; + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Wait forever", + cwd: workspaceDir, + model: "test-model", + verifyChecks: ["test -f never.txt"], + }); + const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); + const stopPromise = service.stopLoop(loop.id); + let closeWaitError: unknown; + + try { + await waitForCancelledAgent(cancelledAgentIds, workerAgentId); + await waitForCancelledAgent(closedAgentIds, workerAgentId); + } catch (error) { + closeWaitError = error; + } finally { + release?.(); + } + + const stopped = await stopPromise; + if (closeWaitError) { + throw closeWaitError; + } + expect(stopped.status).toBe("stopped"); + expect(cancelledAgentIds).toEqual([workerAgentId]); + expect(closedAgentIds).toContain(workerAgentId); + }); + + test("tolerates a loop worker closing while graceful cancellation is refused", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const cancelledAgentIds: string[] = []; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }), + }, + registry: storage, + logger, + }); + const closeAgent = manager.closeAgent.bind(manager); + manager.cancelAgentRun = async (agentId) => { + cancelledAgentIds.push(agentId); + await closeAgent(agentId); + return { status: "refused" }; + }; + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Finish while Stop is canceling", + cwd: workspaceDir, + model: "test-model", + verifyChecks: ["test -f never.txt"], + }); + const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); + const stopPromise = service.stopLoop(loop.id); + + await waitForCancelledAgent(cancelledAgentIds, workerAgentId); + release?.(); + + await expect(stopPromise).resolves.toMatchObject({ status: "stopped" }); + }); + + test("reports unexpected loop worker cancellation errors", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }), + }, + registry: storage, + logger, + }); + manager.cancelAgentRun = async () => { + throw new Error("cancellation transport failed"); + }; + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Fail while Stop is canceling", + cwd: workspaceDir, + model: "test-model", + verifyChecks: ["test -f never.txt"], + }); + await waitForActiveWorkerRun(service, manager, loop.id); + const execution = ( + service as unknown as { running: Map }> } + ).running.get(loop.id)?.promise; + + try { + await expect(service.stopLoop(loop.id)).rejects.toThrow("cancellation transport failed"); + } finally { + release?.(); + await execution; + } + }); + test("stops while waiting for loop workspace provisioning without starting a worker", async () => { let resolveWorkspace: ((workspaceId: string) => void) | null = null; const workspaceProvisioned = new Promise((resolve) => { diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index e8e24b93f..99e5f8ff3 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -10,7 +10,7 @@ import { type EnsureWorkspaceForCreate, formatProviderModel, } from "./agent/create-agent/create.js"; -import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentManager, AgentRunCancellationResult } from "./agent/agent-manager.js"; import { buildStructuredAgentResponsePrompt, getStructuredAgentResponse, @@ -232,9 +232,19 @@ function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string { return `${prefix} [loop ${iterationIndex} verifier]`; } +function isUnknownLoopAgentError(error: unknown, agentId: string): boolean { + return error instanceof Error && error.message === `Unknown agent '${agentId}'`; +} + type LoopAgentManager = Pick< AgentManager, - "archiveAgent" | "cancelAgentRun" | "closeAgent" | "runAgent" | "subscribe" | "waitForAgentEvent" + | "archiveAgent" + | "cancelAgentRun" + | "closeAgent" + | "getAgent" + | "runAgent" + | "subscribe" + | "waitForAgentEvent" >; interface LoopExecutionContext { @@ -523,10 +533,10 @@ export class LoopService { if (running) { if (loop.activeWorkerAgentId) { - await this.options.agentManager.cancelAgentRun(loop.activeWorkerAgentId).catch(() => {}); + await this.stopInternalAgent(loop.activeWorkerAgentId, loop.archive); } if (loop.activeVerifierAgentId) { - await this.options.agentManager.cancelAgentRun(loop.activeVerifierAgentId).catch(() => {}); + await this.stopInternalAgent(loop.activeVerifierAgentId, loop.archive); } await running.promise.catch(() => {}); } else { @@ -539,6 +549,35 @@ export class LoopService { return cloneLoop(loop); } + private async stopInternalAgent(agentId: string, archive: boolean): Promise { + let cancellation: AgentRunCancellationResult; + try { + cancellation = await this.options.agentManager.cancelAgentRun(agentId); + } catch (error) { + if (isUnknownLoopAgentError(error, agentId)) { + return; + } + throw error; + } + if (cancellation.status !== "refused") { + return; + } + if (!this.options.agentManager.getAgent(agentId)) { + return; + } + try { + if (archive) { + await this.options.agentManager.archiveAgent(agentId); + return; + } + await this.options.agentManager.closeAgent(agentId); + } catch (error) { + if (!isUnknownLoopAgentError(error, agentId)) { + throw error; + } + } + } + private async executeLoop(loopId: string, signal: AbortSignal): Promise { const loop = this.requireLoop(loopId); const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null; diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 527655e1b..1dc6c260b 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,9 +1,17 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, open, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js"; +import { + acquirePidLock, + getPidLockInfo, + isLocked, + PidLockError, + refreshPidLock, + releasePidLock, + updatePidLock, +} from "./pid-lock.js"; describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { @@ -22,6 +30,7 @@ describe("pid-lock ownership", () => { const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); expect(lock?.listen).toBeNull(); + expect(lock?.heartbeat).toBe(true); await ( updatePidLock as unknown as ( @@ -49,4 +58,200 @@ describe("pid-lock ownership", () => { await rm(paseoHome, { recursive: true, force: true }); } }); + + test("keeps a stale heartbeat lock when the recorded pid is alive without a reachability check", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-heartbeat-")); + const replacementOwnerPid = process.pid + 10_000; + + try { + const pidPath = join(paseoHome, "paseo.pid"); + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await expect(isLocked(paseoHome)).resolves.toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("reclaims a stale desktop heartbeat lock after desktop confirms the daemon is unreachable", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-desktop-heartbeat-")); + const replacementOwnerPid = process.pid + 10_000; + + try { + const pidPath = join(paseoHome, "paseo.pid"); + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await acquirePidLock(paseoHome, null, { + ownerPid: replacementOwnerPid, + reclaimStaleDesktopLock: true, + }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(replacementOwnerPid); + expect(lock?.listen).toBeNull(); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a stale live lock written by a pre-heartbeat daemon", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-live-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("reclaims a stale legacy desktop lock after desktop confirms the daemon is unreachable", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-desktop-")); + const replacementOwnerPid = process.pid + 10_000; + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await acquirePidLock(paseoHome, null, { + ownerPid: replacementOwnerPid, + reclaimStaleDesktopLock: true, + }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(replacementOwnerPid); + expect(lock?.heartbeat).toBe(true); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("rejects a heartbeat refresh after another supervisor takes ownership", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-refresh-owner-")); + + try { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }); + + await expect(refreshPidLock(paseoHome, { ownerPid: process.pid })).rejects.toBeInstanceOf( + PidLockError, + ); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("retries a heartbeat refresh while its owner is rewriting the lock", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-refresh-rewrite-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + const lock = await getPidLockInfo(paseoHome); + expect(lock).not.toBeNull(); + + const rewriteHandle = await open(pidPath, "r+"); + await rewriteHandle.truncate(0); + + const refresh = refreshPidLock(paseoHome, { ownerPid: process.pid }); + await new Promise((resolve) => setTimeout(resolve, 250)); + await rewriteHandle.writeFile(JSON.stringify(lock)); + await rewriteHandle.close(); + + await expect(refresh).resolves.toBeUndefined(); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a fresh lock when the recorded pid is alive", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-fresh-heartbeat-")); + + try { + await writeFile( + join(paseoHome, "paseo.pid"), + JSON.stringify({ + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: "current-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + expect(lock?.listen).toBe("127.0.0.1:6767"); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 96d663e89..9202f49df 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,4 +1,5 @@ -import { open, readFile, unlink, mkdir } from "node:fs/promises"; +import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { hostname } from "node:os"; @@ -11,6 +12,7 @@ export const pidLockInfoSchema = z.object({ uid: z.number(), listen: z.string().nullable(), desktopManaged: z.boolean().optional(), + heartbeat: z.literal(true).optional(), }); export interface PidLockInfo extends z.infer {} @@ -34,6 +36,12 @@ export class PidLockError extends Error { } } +// Stale recovery is for abandoned locks, so keep this well above ordinary event-loop stalls. +const PID_LOCK_STALE_MS = 5 * 60_000; +const PID_LOCK_HEARTBEAT_INTERVAL_MS = 30_000; +const PID_LOCK_READ_RETRY_ATTEMPTS = 10; +const PID_LOCK_READ_RETRY_DELAY_MS = 50; + function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); @@ -47,6 +55,29 @@ function getPidFilePath(paseoHome: string): string { return join(paseoHome, "paseo.pid"); } +async function isPidLockFresh(pidPath: string): Promise { + try { + const lockStat = await stat(pidPath); + return lockStat.mtimeMs >= Date.now() - PID_LOCK_STALE_MS; + } catch { + return false; + } +} + +async function touchPidLockFile(pidPath: string): Promise { + const now = new Date(); + await utimes(pidPath, now, now); +} + +async function readPidLock(pidPath: string): Promise { + try { + const content = await readFile(pidPath, "utf-8"); + return parsePidLockInfo(JSON.parse(content)); + } catch { + return null; + } +} + function resolveOwnerPid(ownerPid?: number): number { if (typeof ownerPid === "number" && Number.isInteger(ownerPid) && ownerPid > 0) { return ownerPid; @@ -54,10 +85,91 @@ function resolveOwnerPid(ownerPid?: number): number { return process.pid; } +interface AcquirePidLockOptions { + ownerPid?: number; + reclaimStaleDesktopLock?: boolean; +} + +function canReclaimLiveLock( + lock: PidLockInfo, + options: AcquirePidLockOptions | undefined, +): boolean { + // COMPAT(pidLockHeartbeat): v0.1.108 desktop startup has already confirmed the old daemon is + // unreachable before it launches the supervisor. Remove after 2027-01-15. + return options?.reclaimStaleDesktopLock === true && lock.desktopManaged === true; +} + +function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { + return left.pid === right.pid && left.startedAt === right.startedAt; +} + +function createLockHeldError(lock: PidLockInfo): PidLockError { + return new PidLockError( + `Another Paseo daemon is already running (PID ${lock.pid}, started ${lock.startedAt})`, + lock, + ); +} + +async function clearExistingPidLock( + pidPath: string, + existingLock: PidLockInfo, + lockOwnerPid: number, + options: AcquirePidLockOptions | undefined, +): Promise<"already_owned" | "cleared"> { + const lockOwnerRunning = isPidRunning(existingLock.pid); + if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { + await touchPidLockFile(pidPath); + return "already_owned"; + } + + if (lockOwnerRunning) { + const reclaimable = canReclaimLiveLock(existingLock, options); + if (!reclaimable || (await isPidLockFresh(pidPath))) { + throw createLockHeldError(existingLock); + } + + // Re-read immediately before unlinking so a heartbeat at the stale boundary wins. + const confirmedLock = await readPidLock(pidPath); + if ( + !confirmedLock || + !isSamePidLock(existingLock, confirmedLock) || + (await isPidLockFresh(pidPath)) + ) { + throw new PidLockError("PID lock changed while checking whether it was abandoned"); + } + } + + await unlink(pidPath).catch(() => {}); + return "cleared"; +} + +async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise { + let fd; + try { + fd = await open(pidPath, "wx"); + await fd.write(JSON.stringify(lockInfo)); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") { + throw error; + } + + const raceLock = await readPidLock(pidPath); + if (raceLock) { + throw new PidLockError( + `Another Paseo daemon is already running (PID ${raceLock.pid})`, + raceLock, + ); + } + throw new PidLockError("Failed to acquire PID lock due to race condition"); + } finally { + await fd?.close(); + } +} + export async function acquirePidLock( paseoHome: string, listen: string | null, - options?: { ownerPid?: number }, + options?: AcquirePidLockOptions, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -67,29 +179,15 @@ export async function acquirePidLock( } // Try to read existing lock - let existingLock: PidLockInfo | null = null; - try { - const content = await readFile(pidPath, "utf-8"); - existingLock = parsePidLockInfo(JSON.parse(content)); - } catch { - // No existing lock or invalid JSON - that's fine - } + const existingLock = await readPidLock(pidPath); // Check if existing lock is stale const lockOwnerPid = resolveOwnerPid(options?.ownerPid); if (existingLock) { - if (isPidRunning(existingLock.pid)) { - if (existingLock.pid === lockOwnerPid) { - return; - } - - throw new PidLockError( - `Another Paseo daemon is already running (PID ${existingLock.pid}, started ${existingLock.startedAt})`, - existingLock, - ); + const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid, options); + if (result === "already_owned") { + return; } - // Stale lock - remove it - await unlink(pidPath).catch(() => {}); } // Create new lock with exclusive flag @@ -99,36 +197,103 @@ export async function acquirePidLock( hostname: hostname(), uid: process.getuid?.() ?? 0, listen, + heartbeat: true, ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; + await writeNewPidLock(pidPath, lockInfo); +} + +export async function refreshPidLock( + paseoHome: string, + options?: { ownerPid?: number }, +): Promise { + const pidPath = getPidFilePath(paseoHome); + const lockOwnerPid = resolveOwnerPid(options?.ownerPid); let fd; try { - fd = await open(pidPath, "wx"); - await fd.write(JSON.stringify(lockInfo)); - } catch (err) { - if (isErrnoException(err) && err.code === "EEXIST") { - // Race condition - another process created the file - // Re-read and check - try { - const content = await readFile(pidPath, "utf-8"); - const raceLock = parsePidLockInfo(JSON.parse(content)); - if (raceLock) { - throw new PidLockError( - `Another Paseo daemon is already running (PID ${raceLock.pid})`, - raceLock, - ); - } - throw new PidLockError("Failed to acquire PID lock due to race condition"); - } catch (innerErr) { - if (innerErr instanceof PidLockError) throw innerErr; - throw new PidLockError("Failed to acquire PID lock due to race condition"); - } + fd = await open(pidPath, "r+"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + throw new PidLockError("Cannot refresh PID lock: lock file is missing"); } - throw err; - } finally { - await fd?.close(); + throw error; } + + try { + const lock = await readPidLockFromHandleWithRetry(fd); + if (!lock) { + throw new PidLockError("Cannot refresh PID lock: invalid lock file"); + } + if (lock.pid !== lockOwnerPid) { + throw new PidLockError(`Cannot refresh PID lock owned by PID ${lock.pid}`, lock); + } + const now = new Date(); + await fd.utimes(now, now); + } finally { + await fd.close(); + } +} + +async function readPidLockFromHandle(fd: FileHandle): Promise { + try { + const { size } = await fd.stat(); + if (size === 0) { + return null; + } + const content = Buffer.alloc(size); + const { bytesRead } = await fd.read(content, 0, size, 0); + return parsePidLockInfo(JSON.parse(content.subarray(0, bytesRead).toString("utf-8"))); + } catch { + return null; + } +} + +async function readPidLockFromHandleWithRetry(fd: FileHandle): Promise { + for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt += 1) { + const lock = await readPidLockFromHandle(fd); + if (lock) { + return lock; + } + if (attempt < PID_LOCK_READ_RETRY_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); + } + } + return null; +} + +export function startPidLockHeartbeat( + paseoHome: string, + options?: { + ownerPid?: number; + intervalMs?: number; + onError?: (error: unknown) => void; + }, +): () => void { + const intervalMs = options?.intervalMs ?? PID_LOCK_HEARTBEAT_INTERVAL_MS; + let refreshing = false; + + const timer = setInterval(() => { + if (refreshing) { + return; + } + refreshing = true; + refreshPidLock(paseoHome, { ownerPid: options?.ownerPid }) + .catch((error) => { + if (options?.onError) { + options.onError(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`PID lock heartbeat failed: ${message}\n`); + }) + .finally(() => { + refreshing = false; + }); + }, intervalMs); + timer.unref(); + + return () => clearInterval(timer); } export async function updatePidLock( @@ -138,23 +303,23 @@ export async function updatePidLock( ): Promise { const pidPath = getPidFilePath(paseoHome); const lockOwnerPid = resolveOwnerPid(options?.ownerPid); - const content = await readFile(pidPath, "utf-8"); - const existingLock = parsePidLockInfo(JSON.parse(content)); - if (!existingLock) { - throw new PidLockError("Cannot update PID lock: invalid lock file"); - } - - if (existingLock.pid !== lockOwnerPid) { - throw new PidLockError(`Cannot update PID lock owned by PID ${existingLock.pid}`, existingLock); - } - - const updatedLock: PidLockInfo = { - ...existingLock, - ...patch, - }; - const fd = await open(pidPath, "r+"); try { + const existingLock = await readPidLockFromHandleWithRetry(fd); + if (!existingLock) { + throw new PidLockError("Cannot update PID lock: invalid lock file"); + } + if (existingLock.pid !== lockOwnerPid) { + throw new PidLockError( + `Cannot update PID lock owned by PID ${existingLock.pid}`, + existingLock, + ); + } + + const updatedLock: PidLockInfo = { + ...existingLock, + ...patch, + }; await fd.truncate(0); await fd.writeFile(JSON.stringify(updatedLock)); } finally { @@ -182,12 +347,7 @@ export async function releasePidLock( export async function getPidLockInfo(paseoHome: string): Promise { const pidPath = getPidFilePath(paseoHome); - try { - const content = await readFile(pidPath, "utf-8"); - return parsePidLockInfo(JSON.parse(content)); - } catch { - return null; - } + return readPidLock(pidPath); } export async function isLocked( diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index d85ce7f2b..ba7fabe7e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -43,6 +43,7 @@ import type { } from "../services/github-service.js"; interface SessionHandlerInternals { + interruptAgentIfRunning(agentId: string): Promise; handleSendAgentMessage( agentId: string, text: string, @@ -89,6 +90,85 @@ function createBinaryMessageHandler( }; } +test("interruptAgentIfRunning rejects when graceful cancellation is refused", async () => { + const agentId = "11111111-1111-4111-8111-111111111111"; + const session = createSessionForTest({ + agentManager: { + getAgent: vi.fn(() => ({ id: agentId, provider: "codex", lifecycle: "running" })), + hasInFlightRun: vi.fn(() => true), + cancelAgentRun: vi.fn(async () => ({ status: "refused" as const })), + }, + }); + + await expect(asSessionInternals(session).interruptAgentIfRunning(agentId)).rejects.toThrow( + "active run cancellation was not acknowledged", + ); +}); + +test("cancel_agent_request reports refusal only through its response", async () => { + const agentId = "11111111-1111-4111-8111-111111111111"; + const messages: SessionOutboundMessage[] = []; + const getAgent = vi + .fn() + .mockReturnValueOnce({ id: agentId, provider: "codex", lifecycle: "running" }) + .mockReturnValue(null); + const session = createSessionForTest({ + messages, + agentManager: { + getAgent, + hasInFlightRun: vi.fn(() => true), + cancelAgentRun: vi.fn(async () => ({ status: "refused" as const })), + }, + }); + + await session.handleMessage({ + type: "cancel_agent_request", + agentId, + requestId: "cancel-refused", + }); + + expect(messages).toEqual([ + { + type: "cancel_agent_response", + payload: { + requestId: "cancel-refused", + agentId, + agent: null, + error: + "Cannot stop agent 11111111-1111-4111-8111-111111111111 because its active run cancellation was not acknowledged", + }, + }, + ]); +}); + +test("legacy cancel_agent_request reports refusal through the activity log", async () => { + const agentId = "11111111-1111-4111-8111-111111111111"; + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { + getAgent: vi.fn(() => ({ id: agentId, provider: "codex", lifecycle: "running" })), + hasInFlightRun: vi.fn(() => true), + cancelAgentRun: vi.fn(async () => ({ status: "refused" as const })), + }, + }); + + await session.handleMessage({ type: "cancel_agent_request", agentId }); + + expect(messages).toEqual([ + { + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: + "Failed to cancel running agent on request: Cannot stop agent 11111111-1111-4111-8111-111111111111 because its active run cancellation was not acknowledged", + }, + }, + ]); +}); + const checkoutGitMocks = vi.hoisted(() => ({ checkoutResolvedBranch: vi.fn(), commitChanges: vi.fn(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cc38733b2..48e44ab66 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -66,7 +66,7 @@ import { normalizeClientRestartRpcReason, } from "./lifecycle-reasons.js"; -import { AgentManager } from "./agent/agent-manager.js"; +import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { AgentManagerEvent, @@ -100,13 +100,12 @@ import { type TimelineProjectionMode, } from "./agent/timeline-projection.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; +import { buildAgentPrompt } from "./agent/prompt-attachments.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, type AgentPersistenceHandle, type AgentPermissionResponse, - type AgentPromptContentBlock, - type AgentPromptInput, type AgentRunOptions, type AgentSessionConfig, } from "./agent/agent-sdk-types.js"; @@ -1027,33 +1026,6 @@ export class Session { // No unsolicited agent list hydration. Callers must use fetch_agents_request. } - /** - * Normalize a user prompt (with optional image metadata) for AgentManager - */ - private buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], - ): AgentPromptInput { - const normalized = text?.trim() ?? ""; - const hasImages = Boolean(images && images.length > 0); - const hasAttachments = Boolean(attachments && attachments.length > 0); - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; - } - /** * Interrupt the agent's active run so the next prompt starts a fresh turn. * Returns once the manager confirms the stream has been cancelled. @@ -1085,16 +1057,17 @@ export class Session { ); const t0 = Date.now(); - const cancelled = await this.agentManager.cancelAgentRun(agentId); + const cancellation = await this.agentManager.cancelAgentRun(agentId); this.sessionLogger.debug( - { agentId, cancelled, durationMs: Date.now() - t0 }, + { agentId, cancellation: cancellation.status, durationMs: Date.now() - t0 }, "interruptAgentIfRunning: cancelAgentRun completed", ); - if (!cancelled) { + if (cancellation.status === "refused") { this.sessionLogger.warn( { agentId }, "interruptAgentIfRunning: reported running but no active run was cancelled", ); + throw new AgentRunCancellationError(agentId, "stop"); } } @@ -2438,7 +2411,7 @@ export class Session { ); const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; - const prompt = this.buildAgentPrompt(promptText, images, attachments); + const prompt = buildAgentPrompt(promptText, images, attachments); try { await sendPromptToAgent({ @@ -2857,11 +2830,30 @@ export class Session { requestId, agentId, agent: payload, + error: null, }, }); } } catch (error) { - this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request"); + if (requestId) { + this.sessionLogger.error( + { err: error, agentId }, + `Failed to cancel running agent on request for agent ${agentId}`, + ); + const agent = this.agentManager.getAgent(agentId); + const payload = agent ? await this.buildAgentPayload(agent) : null; + this.emit({ + type: "cancel_agent_response", + payload: { + requestId, + agentId, + agent: payload, + error: errorToFriendlyMessage(error), + }, + }); + } else { + this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request"); + } } } @@ -5502,12 +5494,15 @@ export class Session { logger: this.sessionLogger, }); const agentPayload = await this.buildAgentPayload(snapshot); - const rows = this.agentManager.fetchTimeline(msg.agentId, { + const timeline = this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0, - }).rows; + }); const forkContext = buildAgentForkContextAttachment({ - rows, + rows: timeline.rows, + cursorBoundary: msg.boundaryCursor + ? { timelineEpoch: timeline.epoch, cursor: msg.boundaryCursor } + : null, boundaryMessageId: msg.boundaryMessageId, agentTitle: agentPayload.title, cwd: snapshot.cwd, @@ -5520,6 +5515,7 @@ export class Session { agentId: msg.agentId, attachment: forkContext.attachment, itemCount: forkContext.itemCount, + boundaryCursor: forkContext.boundaryCursor, boundaryMessageId: forkContext.boundaryMessageId, error: null, }, @@ -5536,6 +5532,7 @@ export class Session { agentId: msg.agentId, attachment: null, itemCount: 0, + boundaryCursor: msg.boundaryCursor ?? null, boundaryMessageId: msg.boundaryMessageId ?? null, error: error instanceof Error ? error.message : String(error), }, @@ -5563,7 +5560,7 @@ export class Session { try { const agentId = resolved.agentId; - const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments); + const prompt = buildAgentPrompt(msg.text, msg.images, msg.attachments); this.sessionLogger.trace( { agentId, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 5475a5854..f7aefff5c 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1609,7 +1609,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy archivedAt: null, }; const killTerminal = vi.fn(); - const cancelAgentRun = vi.fn(async () => true); + const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ clientId: "test-client", diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index 896546867..d6232ff4d 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -110,6 +110,32 @@ async function settle(): Promise { } describe("VoiceSession streaming transcription", () => { + test("surfaces a refused voice-mode agent interruption", async () => { + const { voiceSession, host } = createVoiceSession(); + host.interruptAgentIfRunning = vi.fn(async () => { + throw new Error("active run cancellation was not acknowledged"); + }); + + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + + await expect(voiceSession.handleAbort()).rejects.toThrow( + "active run cancellation was not acknowledged", + ); + expect(host.interruptAgentIfRunning).toHaveBeenCalledWith(VOICE_AGENT_ID); + expect(host.emitted).toContainEqual( + expect.objectContaining({ + type: "activity_log", + payload: expect.objectContaining({ + type: "error", + content: "Voice interruption failed: active run cancellation was not acknowledged", + metadata: { voiceAbortFailed: true }, + }), + }), + ); + + await voiceSession.cleanup(); + }); + test("delivers the streaming final transcript to the agent exactly once", async () => { const { voiceSession, detector, sttSession, host } = createVoiceSession(); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index 2ac9998ed..f6331dba3 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -1084,10 +1084,18 @@ export class VoiceSession { try { await this.host.interruptAgentIfRunning(this.voiceModeAgentId); } catch (error) { - this.sessionLogger.warn( - { err: error, agentId: this.voiceModeAgentId }, - "Failed to interrupt active voice-mode agent on abort", - ); + const message = `Voice interruption failed: ${getErrorMessage(error)}`; + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: message, + metadata: { voiceAbortFailed: true }, + }, + }); + throw error; } } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 05ce7e886..ff371979a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1260,6 +1260,8 @@ export class VoiceAssistantWebSocketServer { daemonSelfUpdate: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. diff --git a/packages/website/src/android-version.test.ts b/packages/website/src/android-version.test.ts new file mode 100644 index 000000000..fa8efba09 --- /dev/null +++ b/packages/website/src/android-version.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getAndroidVersionCode } from "./android-version"; + +describe("getAndroidVersionCode", () => { + it("matches the Android build version code for a stable release", () => { + expect(getAndroidVersionCode("0.1.107")).toBe(1107); + }); + + it("rejects versions that cannot map to a unique Android version code", () => { + expect(() => getAndroidVersionCode("0.1000.0")).toThrow( + "Cannot derive collision-free Android versionCode from version: 0.1000.0", + ); + }); +}); diff --git a/packages/website/src/android-version.ts b/packages/website/src/android-version.ts new file mode 100644 index 000000000..250c0df32 --- /dev/null +++ b/packages/website/src/android-version.ts @@ -0,0 +1,22 @@ +export function getAndroidVersionCode(version: string): number { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); + if (!match) { + throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`); + } + + const [, majorText, minorText, patchText] = match; + const major = Number(majorText); + const minor = Number(minorText); + const patch = Number(patchText); + + if (minor > 999 || patch > 999) { + throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`); + } + + const versionCode = major * 1_000_000 + minor * 1_000 + patch; + if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) { + throw new Error(`Derived Android versionCode is out of range: ${versionCode}`); + } + + return versionCode; +} diff --git a/packages/website/src/cloudflare-cache.ts b/packages/website/src/cloudflare-cache.ts new file mode 100644 index 000000000..2c61baaf7 --- /dev/null +++ b/packages/website/src/cloudflare-cache.ts @@ -0,0 +1,9 @@ +import { env, waitUntil } from "cloudflare:workers"; +import type { WebsiteCacheContext } from "./github-cache"; + +export function getWebsiteCacheContext(): WebsiteCacheContext { + return { + cache: (env as { WEBSITE_CACHE?: KVNamespace }).WEBSITE_CACHE ?? null, + waitUntil, + }; +} diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 09c9278d4..5c0ca9fa0 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -84,7 +84,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { {/* Content section */} -
+
diff --git a/packages/website/src/github-cache.ts b/packages/website/src/github-cache.ts index 9e9c9daf6..8ee08457e 100644 --- a/packages/website/src/github-cache.ts +++ b/packages/website/src/github-cache.ts @@ -1,7 +1,10 @@ -import { env, waitUntil } from "cloudflare:workers"; - export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000; +export interface WebsiteCacheContext { + cache: KVNamespace | null; + waitUntil: (promise: Promise) => void; +} + interface CachedValue { fetchedAt: number; value: T; @@ -9,10 +12,6 @@ interface CachedValue { type Validator = (value: unknown) => value is T; -function getWebsiteCache(): KVNamespace | null { - return (env as { WEBSITE_CACHE?: KVNamespace }).WEBSITE_CACHE ?? null; -} - function isCachedValue(value: unknown, isValue: Validator): value is CachedValue { if (typeof value !== "object" || value === null) return false; const record = value as Record; @@ -20,17 +19,20 @@ function isCachedValue(value: unknown, isValue: Validator): value is Cache } async function readCachedValue( + cache: KVNamespace | null, key: string, isValue: Validator, ): Promise | null> { - const cache = getWebsiteCache(); if (!cache) return null; const cached = await cache.get(key, { cacheTtl: 60, type: "json" }); return isCachedValue(cached, isValue) ? cached : null; } -async function writeCachedValue(key: string, value: T): Promise { - const cache = getWebsiteCache(); +async function writeCachedValue( + cache: KVNamespace | null, + key: string, + value: T, +): Promise { if (!cache) return; await cache.put( key, @@ -42,20 +44,22 @@ async function writeCachedValue(key: string, value: T): Promise { } export async function getBlockingColdCache({ + context, key, isValue, fetchFresh, }: { + context: WebsiteCacheContext; key: string; isValue: Validator; fetchFresh: () => Promise; }): Promise { - const cached = await readCachedValue(key, isValue); + const cached = await readCachedValue(context.cache, key, isValue); if (cached) { if (Date.now() - cached.fetchedAt > GITHUB_CACHE_TTL_MS) { - waitUntil( + context.waitUntil( fetchFresh() - .then((fresh) => writeCachedValue(key, fresh)) + .then((fresh) => writeCachedValue(context.cache, key, fresh)) .catch(() => undefined), ); } @@ -63,6 +67,6 @@ export async function getBlockingColdCache({ } const fresh = await fetchFresh(); - await writeCachedValue(key, fresh); + await writeCachedValue(context.cache, key, fresh); return fresh; } diff --git a/packages/website/src/latest-release.test.ts b/packages/website/src/latest-release.test.ts new file mode 100644 index 000000000..e10c26446 --- /dev/null +++ b/packages/website/src/latest-release.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getLatestAndroidVersionFromReleases, type GitHubRelease } from "./latest-release"; + +function release({ + version, + hasApk, + prerelease = false, +}: { + version: string; + hasApk: boolean; + prerelease?: boolean; +}): GitHubRelease { + const tag = `v${version}`; + return { + tag_name: tag, + assets: hasApk ? [{ name: `paseo-${tag}-android.apk` }] : [], + prerelease, + draft: false, + }; +} + +describe("getLatestAndroidVersionFromReleases", () => { + it("selects the latest stable release that contains an Android APK", () => { + const releases = [ + release({ version: "0.1.109", hasApk: true, prerelease: true }), + release({ version: "0.1.108", hasApk: false }), + release({ version: "0.1.107", hasApk: true }), + ]; + + expect(getLatestAndroidVersionFromReleases(releases)).toBe("0.1.107"); + }); +}); diff --git a/packages/website/src/latest-release.ts b/packages/website/src/latest-release.ts new file mode 100644 index 000000000..afdbf6b56 --- /dev/null +++ b/packages/website/src/latest-release.ts @@ -0,0 +1,160 @@ +import { getBlockingColdCache, type WebsiteCacheContext } from "./github-cache"; + +interface GitHubAsset { + name: string; +} + +export interface GitHubRelease { + tag_name: string; + assets: GitHubAsset[]; + prerelease: boolean; + draft: boolean; +} + +export interface ReleaseInfo { + version: string; + linuxAppImageAsset: string; + windowsX64Asset: string | null; + windowsArm64Asset: string | null; +} + +const LINUX_APPIMAGE_ASSET_PATTERN = + /^Paseo-(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)-)?x86_64\.AppImage$/; + +const REQUIRED_ASSET_PATTERNS = [ + /Paseo-.*-arm64\.dmg$/, + LINUX_APPIMAGE_ASSET_PATTERN, + /Paseo-Setup-.*\.exe$/, +]; + +const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; +const RELEASE_CACHE_KEY = "github-release:v1"; +const ANDROID_RELEASE_CACHE_KEY = "github-android-release:v1"; + +function hasRequiredAssets(release: GitHubRelease): boolean { + return REQUIRED_ASSET_PATTERNS.every((pattern) => + release.assets.some((asset) => pattern.test(asset.name)), + ); +} + +function pickWindowsAssets(assets: GitHubAsset[]) { + const x64Suffixed = assets.find((asset) => /Paseo-Setup-.*-x64\.exe$/.test(asset.name)); + const arm64 = assets.find((asset) => /Paseo-Setup-.*-arm64\.exe$/.test(asset.name)); + const legacy = assets.find( + (asset) => + /Paseo-Setup-.*\.exe$/.test(asset.name) && + !asset.name.endsWith("-x64.exe") && + !asset.name.endsWith("-arm64.exe"), + ); + return { + x64: (x64Suffixed ?? legacy)?.name ?? null, + arm64: arm64?.name ?? null, + }; +} + +function pickLinuxAppImageAsset(assets: GitHubAsset[]) { + return assets.find((asset) => LINUX_APPIMAGE_ASSET_PATTERN.test(asset.name))?.name ?? null; +} + +function versionFromTag(tag: string): string { + return tag.replace(/^v/, ""); +} + +async function fetchGitHubReleases(): Promise { + const response = await fetch(GITHUB_RELEASES_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "paseo-website", + }, + cf: { + cacheEverything: true, + cacheTtl: 60, + cacheKey: "github-releases-latest", + }, + } as RequestInit); + if (!response.ok) throw new Error(`github releases ${response.status}`); + + return (await response.json()) as GitHubRelease[]; +} + +async function fetchLatestReadyRelease(): Promise { + const releases = await fetchGitHubReleases(); + const ready = releases.find( + (release) => !release.prerelease && !release.draft && hasRequiredAssets(release), + ); + if (!ready) throw new Error("no ready GitHub release found"); + + const windowsAssets = pickWindowsAssets(ready.assets); + const linuxAppImageAsset = pickLinuxAppImageAsset(ready.assets); + if (!linuxAppImageAsset) throw new Error("ready release missing Linux AppImage asset"); + + return { + version: versionFromTag(ready.tag_name), + linuxAppImageAsset, + windowsX64Asset: windowsAssets.x64, + windowsArm64Asset: windowsAssets.arm64, + }; +} + +export function getLatestAndroidVersionFromReleases(releases: GitHubRelease[]): string { + const release = releases.find((candidate) => { + if (candidate.prerelease || candidate.draft) return false; + const version = versionFromTag(candidate.tag_name); + if (!/^\d+\.\d+\.\d+$/.test(version)) return false; + return candidate.assets.some( + (asset) => asset.name === `paseo-${candidate.tag_name}-android.apk`, + ); + }); + if (!release) throw new Error("no stable GitHub release with an Android APK found"); + return versionFromTag(release.tag_name); +} + +async function fetchLatestAndroidVersion(): Promise { + return getLatestAndroidVersionFromReleases(await fetchGitHubReleases()); +} + +function isAndroidVersion(value: unknown): value is string { + return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value); +} + +function isReleaseInfo(value: unknown): value is ReleaseInfo { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + typeof record.version === "string" && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(record.version) && + typeof record.linuxAppImageAsset === "string" && + (record.linuxAppImageAsset === "Paseo-x86_64.AppImage" || + new RegExp(`^Paseo-${record.version.replaceAll(".", "\\.")}-x86_64\\.AppImage$`).test( + record.linuxAppImageAsset, + )) && + (typeof record.windowsX64Asset === "string" || record.windowsX64Asset === null) && + (typeof record.windowsArm64Asset === "string" || record.windowsArm64Asset === null) && + (record.windowsX64Asset === null || + new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}(?:-x64)?\\.exe$`).test( + record.windowsX64Asset, + )) && + (record.windowsArm64Asset === null || + new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}-arm64\\.exe$`).test( + record.windowsArm64Asset, + )) + ); +} + +export async function getLatestReleaseInfo(context: WebsiteCacheContext): Promise { + return getBlockingColdCache({ + context, + key: RELEASE_CACHE_KEY, + isValue: isReleaseInfo, + fetchFresh: fetchLatestReadyRelease, + }); +} + +export async function getLatestAndroidVersion(context: WebsiteCacheContext): Promise { + return getBlockingColdCache({ + context, + key: ANDROID_RELEASE_CACHE_KEY, + isValue: isAndroidVersion, + fetchFresh: fetchLatestAndroidVersion, + }); +} diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts index 815e54ed1..09a759cbf 100644 --- a/packages/website/src/release.ts +++ b/packages/website/src/release.ts @@ -1,121 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; -import { getBlockingColdCache } from "./github-cache"; - -interface GitHubAsset { - name: string; -} - -interface GitHubRelease { - tag_name: string; - assets: GitHubAsset[]; - prerelease: boolean; - draft: boolean; -} - -const LINUX_APPIMAGE_ASSET_PATTERN = - /^Paseo-(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)-)?x86_64\.AppImage$/; - -const REQUIRED_ASSET_PATTERNS = [ - /Paseo-.*-arm64\.dmg$/, // Mac Apple Silicon - LINUX_APPIMAGE_ASSET_PATTERN, // Linux AppImage - /Paseo-Setup-.*\.exe$/, // Windows (any arch) -]; - -function hasRequiredAssets(release: GitHubRelease): boolean { - return REQUIRED_ASSET_PATTERNS.every((pattern) => - release.assets.some((asset) => pattern.test(asset.name)), - ); -} - -function pickWindowsAssets(assets: GitHubAsset[]) { - const x64Suffixed = assets.find((a) => /Paseo-Setup-.*-x64\.exe$/.test(a.name)); - const arm64 = assets.find((a) => /Paseo-Setup-.*-arm64\.exe$/.test(a.name)); - const legacy = assets.find( - (a) => - /Paseo-Setup-.*\.exe$/.test(a.name) && - !a.name.endsWith("-x64.exe") && - !a.name.endsWith("-arm64.exe"), - ); - return { - x64: (x64Suffixed ?? legacy)?.name ?? null, - arm64: arm64?.name ?? null, - }; -} - -function pickLinuxAppImageAsset(assets: GitHubAsset[]) { - return assets.find((a) => LINUX_APPIMAGE_ASSET_PATTERN.test(a.name))?.name ?? null; -} - -function versionFromTag(tag: string): string { - return tag.replace(/^v/, ""); -} - -interface ReleaseInfo { - version: string; - linuxAppImageAsset: string; - windowsX64Asset: string | null; - windowsArm64Asset: string | null; -} - -const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; -const RELEASE_CACHE_KEY = "github-release:v1"; - -async function fetchLatestReadyRelease(): Promise { - const res = await fetch(GITHUB_RELEASES_URL, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "paseo-website", - }, - cf: { - cacheEverything: true, - cacheTtl: 60, - cacheKey: "github-releases-latest", - }, - } as RequestInit); - if (!res.ok) throw new Error(`github releases ${res.status}`); - - const releases = (await res.json()) as GitHubRelease[]; - const ready = releases.find((r) => !r.prerelease && !r.draft && hasRequiredAssets(r)); - if (!ready) throw new Error("no ready GitHub release found"); - const win = pickWindowsAssets(ready.assets); - const linuxAppImageAsset = pickLinuxAppImageAsset(ready.assets); - if (!linuxAppImageAsset) throw new Error("ready release missing Linux AppImage asset"); - return { - version: versionFromTag(ready.tag_name), - linuxAppImageAsset, - windowsX64Asset: win.x64, - windowsArm64Asset: win.arm64, - }; -} - -function isReleaseInfo(value: unknown): value is ReleaseInfo { - if (typeof value !== "object" || value === null) return false; - const record = value as Record; - return ( - typeof record.version === "string" && - /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(record.version) && - typeof record.linuxAppImageAsset === "string" && - (record.linuxAppImageAsset === "Paseo-x86_64.AppImage" || - new RegExp(`^Paseo-${record.version.replaceAll(".", "\\.")}-x86_64\\.AppImage$`).test( - record.linuxAppImageAsset, - )) && - (typeof record.windowsX64Asset === "string" || record.windowsX64Asset === null) && - (typeof record.windowsArm64Asset === "string" || record.windowsArm64Asset === null) && - (record.windowsX64Asset === null || - new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}(?:-x64)?\\.exe$`).test( - record.windowsX64Asset, - )) && - (record.windowsArm64Asset === null || - new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}-arm64\\.exe$`).test( - record.windowsArm64Asset, - )) - ); -} +import { getWebsiteCacheContext } from "./cloudflare-cache"; +import { getLatestReleaseInfo } from "./latest-release"; export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => { - return getBlockingColdCache({ - key: RELEASE_CACHE_KEY, - isValue: isReleaseInfo, - fetchFresh: fetchLatestReadyRelease, - }); + return getLatestReleaseInfo(getWebsiteCacheContext()); }); diff --git a/packages/website/src/routes/changelog.tsx b/packages/website/src/routes/changelog.tsx index 05a5a4881..bbfb19a52 100644 --- a/packages/website/src/routes/changelog.tsx +++ b/packages/website/src/routes/changelog.tsx @@ -4,6 +4,14 @@ import changelogMarkdown from "../../../../CHANGELOG.md?raw"; import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; +interface ChangelogRelease { + version: string; + date: string; + markdown: string; +} + +const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/; + export const Route = createFileRoute("/changelog")({ head: () => pageMeta( @@ -14,12 +22,84 @@ export const Route = createFileRoute("/changelog")({ component: Changelog, }); +function formatDate(date: string): string { + const [year, month, day] = date.split("-").map(Number); + return new Intl.DateTimeFormat("en", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(new Date(Date.UTC(year, month - 1, day))); +} + +function parseChangelog(markdown: string): ChangelogRelease[] { + const releases: ChangelogRelease[] = []; + const versions = new Set(); + let currentRelease: ChangelogRelease | null = null; + + for (const line of markdown.split("\n")) { + const heading = line.match(releaseHeadingPattern); + if (heading) { + const version = heading[1]; + if (versions.has(version)) { + throw new Error(`Duplicate changelog version: ${version}`); + } + versions.add(version); + + if (currentRelease) releases.push(currentRelease); + currentRelease = { + version, + date: heading[2], + markdown: "", + }; + continue; + } + + if (currentRelease) currentRelease.markdown += `${line}\n`; + } + + if (currentRelease) releases.push(currentRelease); + return releases; +} + +const changelogReleases = parseChangelog(changelogMarkdown); + +function Release({ release }: { release: ChangelogRelease }) { + const anchor = `release-${release.version}`; + + return ( +
+ +
+ + + +

+ Paseo {release.version} +

+
+
+ {release.markdown} +
+
+ ); +} + function Changelog() { return ( -
- {changelogMarkdown} -
+
+

Changelog

+ {changelogReleases.map((release) => ( + + ))} +
); } diff --git a/packages/website/src/server-entry.ts b/packages/website/src/server-entry.ts index 1f48e9854..18763f6df 100644 --- a/packages/website/src/server-entry.ts +++ b/packages/website/src/server-entry.ts @@ -1,10 +1,14 @@ import startEntry from "@tanstack/react-start/server-entry"; +import { getAndroidVersionCode } from "~/android-version"; import { getDoc } from "~/docs"; +import { getLatestAndroidVersion } from "~/latest-release"; import { buildLlmsTxt } from "~/llms"; const CANONICAL_HOST = "paseo.sh"; -type FetchArgs = Parameters; +interface WebsiteEnv { + WEBSITE_CACHE?: KVNamespace; +} function markdownResponse(body: string): Response { return new Response(body, { @@ -15,6 +19,15 @@ function markdownResponse(body: string): Response { }); } +function plainTextResponse(body: string): Response { + return new Response(body, { + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=300, s-maxage=300", + }, + }); +} + function docSlugFromMarkdownPath(pathname: string): string | null { if (pathname === "/docs.md") return ""; const match = pathname.match(/^\/docs\/(.+)\.md$/); @@ -22,8 +35,7 @@ function docSlugFromMarkdownPath(pathname: string): string | null { } export default { - async fetch(...args: FetchArgs): Promise { - const [request] = args; + async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise { const url = new URL(request.url); const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1"; @@ -43,6 +55,14 @@ export default { return markdownResponse(buildLlmsTxt()); } + if (url.pathname === "/android-version.txt") { + const version = await getLatestAndroidVersion({ + cache: env.WEBSITE_CACHE ?? null, + waitUntil: (promise) => context.waitUntil(promise), + }); + return plainTextResponse(`${getAndroidVersionCode(version)}\n`); + } + const slug = docSlugFromMarkdownPath(url.pathname); if (slug !== null) { const doc = getDoc(slug); @@ -50,6 +70,6 @@ export default { return markdownResponse(doc.content); } - return startEntry.fetch(...args); + return startEntry.fetch(request); }, }; diff --git a/packages/website/src/stars.ts b/packages/website/src/stars.ts index 003c363bd..d90128640 100644 --- a/packages/website/src/stars.ts +++ b/packages/website/src/stars.ts @@ -1,4 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; +import { getWebsiteCacheContext } from "./cloudflare-cache"; import { getBlockingColdCache } from "./github-cache"; interface GitHubRepo { @@ -38,6 +39,7 @@ function isStars(value: unknown): value is string { export const getStarCount = createServerFn({ method: "GET" }).handler(async () => { const stars = await getBlockingColdCache({ + context: getWebsiteCacheContext(), key: STARS_CACHE_KEY, isValue: isStars, fetchFresh: fetchStarCount, diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 9a2020200..2042f34ac 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -1,5 +1,31 @@ @import "tailwindcss"; +* { + scrollbar-color: var(--color-scrollbar-handle) transparent; + scrollbar-width: thin; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 9999px; + background: var(--color-scrollbar-handle) content-box; +} + +*::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + @keyframes voice-bar { 0% { transform: scaleY(1); @@ -19,11 +45,15 @@ } .social-proof-marquee { - margin-inline: calc(50% - 50vw); + margin-inline: calc(50% - 50cqw); overflow-x: clip; position: relative; } +.landing-content { + container-type: inline-size; +} + .social-proof-marquee::before, .social-proof-marquee::after { content: ""; @@ -93,66 +123,124 @@ font-size: 0.875em; } -/* Markdown rendering styles for /changelog */ -.changelog-markdown { - color: rgba(250, 250, 250, 0.82); +/* Changelog */ +.changelog-release { + padding-bottom: 3rem; +} + +.changelog-release + .changelog-release { + border-top: 1px solid var(--color-border); + padding-top: 3rem; +} + +.changelog-release-date { + display: block; + margin-bottom: 0.625rem; + color: var(--color-muted-foreground); + font-size: 0.8125rem; +} + +.changelog-release-heading { + position: relative; + scroll-margin-top: 1.5rem; + margin-bottom: 1.75rem; +} + +.changelog-release-title { + color: var(--color-foreground); + font-size: 1.25rem; + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.4; +} + +.changelog-release-title span { + color: var(--color-muted-foreground); + font-weight: 400; +} + +.changelog-heading-anchor { + position: absolute; + top: 0; + right: 100%; + bottom: 0; + display: inline-flex; + align-items: center; + padding-right: 0.375rem; + color: color-mix(in srgb, var(--color-foreground) 40%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 400; + text-decoration: none; + opacity: 0; + transition: opacity 150ms ease; +} + +.changelog-release-heading:hover .changelog-heading-anchor, +.changelog-heading-anchor:focus { + opacity: 1; +} + +.changelog-heading-anchor:hover { + color: var(--color-foreground); +} + +.changelog-release-notes { + color: color-mix(in srgb, var(--color-foreground) 76%, transparent); + font-size: 0.9375rem; line-height: 1.7; } -.changelog-markdown h1 { - color: var(--color-foreground); - font-size: 2rem; - line-height: 1.2; - font-weight: 600; - margin-bottom: 1rem; -} - -.changelog-markdown h2 { - color: var(--color-foreground); - font-size: 1.5rem; - line-height: 1.3; - font-weight: 600; - margin-top: 2rem; - margin-bottom: 0.75rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--color-border); -} - -.changelog-markdown h3 { - color: var(--color-foreground); - font-size: 1.125rem; +.changelog-release-notes h3 { + margin-top: 1.75rem; + margin-bottom: 0.625rem; + color: var(--color-muted-foreground); + font-size: 0.875rem; + font-weight: 500; line-height: 1.4; - font-weight: 600; - margin-top: 1.5rem; - margin-bottom: 0.5rem; } -.changelog-markdown p { - margin-top: 0.5rem; - margin-bottom: 0.5rem; +.changelog-release-notes h3:first-child { + margin-top: 0; } -.changelog-markdown ul { - list-style: disc; - margin-left: 1.25rem; - margin-top: 0.75rem; - margin-bottom: 0.75rem; +.changelog-release-notes p { + margin-block: 0.75rem; +} + +.changelog-release-notes ul { display: grid; - gap: 0.5rem; + gap: 0.625rem; + margin-top: 0.75rem; + padding-left: 1.25rem; + list-style: disc; } -.changelog-markdown li { - padding-left: 0.125rem; +.changelog-release-notes li::marker { + color: color-mix(in srgb, var(--color-primary) 65%, var(--color-muted-foreground)); } -.changelog-markdown a { +.changelog-release-notes strong { color: var(--color-foreground); - text-decoration: underline; - text-underline-offset: 2px; + font-weight: 500; } -.changelog-markdown a:hover { - color: rgba(250, 250, 250, 0.8); +.changelog-release-notes a { + color: var(--color-foreground); + text-decoration-color: color-mix(in srgb, var(--color-primary) 65%, transparent); + text-decoration-line: underline; + text-underline-offset: 3px; +} + +.changelog-release-notes a:hover { + color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-primary)); +} + +.changelog-release-notes code { + border-radius: 0.25rem; + background: var(--color-muted); + padding: 0.125rem 0.375rem; + color: var(--color-foreground); + font-size: 0.875em; } /* Docs page prose styles — mirrors the original docs/*.tsx components */ @@ -493,6 +581,7 @@ --color-muted-foreground: #a8adac; --color-card: #171d1c; --color-border: #252b2a; + --color-scrollbar-handle: #717574; --color-primary: #239956; --color-primary-foreground: #ffffff; --color-secondary: #252b2a;