mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge remote-tracking branch 'origin/main' into mac-daemon-repro
# Conflicts: # docs/architecture.md # packages/app/src/desktop/host.ts # packages/desktop/capture-harness/main.js # packages/desktop/src/features/browser-webviews/index.test.ts # packages/desktop/src/features/browser-webviews/index.ts # packages/desktop/src/features/browser-webviews/registry.ts # packages/desktop/src/main.ts # packages/desktop/src/preload.ts
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
rust 1.85.1
|
||||
nodejs 22.20.0
|
||||
java 21
|
||||
android-sdk latest
|
||||
android-sdk 21.0
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 /<lan-ip>:8081` before any JS loads.
|
||||
- **`EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port>`** — 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:<port>` 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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:<port>` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,6 +54,28 @@ async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -61,13 +61,14 @@ export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { openSubagentsTrack } from "./helpers/subagents";
|
||||
interface ProviderSubagentCase {
|
||||
provider: RewindFlowProvider;
|
||||
sentinel: string;
|
||||
expectedName: string;
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[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 });
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<StreamItem, HistoryRowDisplayVariants>();
|
||||
|
||||
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<number | null>(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<StreamItem>): 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) => (
|
||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
||||
));
|
||||
@@ -331,7 +375,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
{liveAuxiliary}
|
||||
</Fragment>
|
||||
);
|
||||
}, [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 (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
strictMode
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
vi.hoisted(() => {
|
||||
@@ -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<StreamViewportHandle>();
|
||||
const liveHead = [userMessage(1)];
|
||||
let label = "collapsed";
|
||||
const renderLiveHeadRow = vi.fn(() => <div>{label}</div>);
|
||||
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<StreamViewportHandle>();
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
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) => (
|
||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
||||
));
|
||||
}, [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 (
|
||||
<>
|
||||
<div
|
||||
ref={handleScrollContainerRef}
|
||||
data-testid="agent-chat-scroll"
|
||||
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
|
||||
style={scrollContainerStyle}
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const item = segments.historyVirtualized[virtualRow.index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderHistoryVirtualizedRow(
|
||||
item,
|
||||
virtualRow.index,
|
||||
segments.historyVirtualized,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
<div
|
||||
ref={handleScrollContainerRef}
|
||||
data-testid="agent-chat-scroll"
|
||||
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
|
||||
style={scrollContainerStyle}
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const item = segments.historyVirtualized[virtualRow.index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
{scrollbarOverlay}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> | 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}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
@@ -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 (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
forkBoundaryMessageId={boundaryMessageId}
|
||||
onFork={onForkAssistantTurn}
|
||||
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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<AgentStreamViewHandle, AgentStreamVi
|
||||
!readOnly &&
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
const supportsAgentForkContextCursor = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -462,7 +473,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundaryMessageId }) => {
|
||||
async ({ target, boundary }) => {
|
||||
try {
|
||||
if (!supportsAgentForkContext) {
|
||||
toast?.error(t("message.actions.forkUnavailable"));
|
||||
@@ -474,10 +485,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const draftSetup = buildForkDraftSetup(context);
|
||||
const prepareForkDraft = async () => {
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
|
||||
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
|
||||
const compactedToolCalls = useMemo(
|
||||
// Keep retained history outside the 48ms live-head flush path.
|
||||
const preparedToolCallHistory = useMemo(
|
||||
() => 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<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const renderSingleToolCallItem = useCallback(
|
||||
(item: Extract<StreamItem, { kind: "tool_call" }>, isLastInSequence: boolean) => {
|
||||
(
|
||||
item: Extract<StreamItem, { kind: "tool_call" }>,
|
||||
isLastInSequence: boolean,
|
||||
maxDetailHeight?: number,
|
||||
) => {
|
||||
const { payload } = item;
|
||||
|
||||
if (payload.source === "agent") {
|
||||
@@ -720,6 +745,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -735,6 +761,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -743,32 +770,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderToolCallItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
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 (
|
||||
<ToolCallGroup
|
||||
<OverviewToolCallGroupView
|
||||
group={group}
|
||||
presentation={toolCallDetailLevel === "concise" ? "concise" : "overview"}
|
||||
expanded={expanded}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
onExpandedChange={setToolCallGroupExpanded}
|
||||
>
|
||||
{group.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{renderSingleToolCallItem(call, index === group.calls.length - 1)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ToolCallGroup>
|
||||
{expanded
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
</OverviewToolCallGroupView>
|
||||
);
|
||||
},
|
||||
[
|
||||
compactedToolCalls.groupsByHostId,
|
||||
projectedToolCalls.groupsByHostId,
|
||||
expandedToolCallGroupIds,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
toolCallDetailLevel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -826,10 +858,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
streamRenderStrategy,
|
||||
supportsAgentForkContextCursor,
|
||||
],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -854,6 +893,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
supportsTimelineCursor={supportsAgentForkContextCursor}
|
||||
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
@@ -864,6 +904,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
streamRenderStrategy,
|
||||
supportsAgentForkContextCursor,
|
||||
],
|
||||
);
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
@@ -960,6 +1001,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: projectedToolCalls.historyGroupUpdatesByHostId,
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
@@ -968,6 +1017,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{streamRenderStrategy.render({
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision: expandedToolCallGroupIds,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { AppState, useWindowDimensions, View } from "react-native";
|
||||
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
@@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
@@ -37,7 +38,16 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
resolveDesktopAppContentMinimum,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
@@ -87,9 +97,17 @@ import {
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
useHasWindowChromeObstruction,
|
||||
WindowChromeProvider,
|
||||
WindowChromeRegion,
|
||||
WindowChromeSafeArea,
|
||||
} from "@/utils/desktop-window";
|
||||
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -397,6 +415,7 @@ interface AppContainerProps {
|
||||
}
|
||||
|
||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
|
||||
|
||||
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||
const daemons = useHosts();
|
||||
@@ -408,6 +427,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => 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 = (
|
||||
<SidebarChrome
|
||||
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
|
||||
mounted={isCompactLayout ? chromeEnabled : desktopSidebarMounted}
|
||||
visible={isCompactLayout ? chromeEnabled : desktopSidebarVisible}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? sidebarChrome : null}
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{sidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
<WindowChromeRegion corners="both">
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
</CompactExplorerSidebarHost>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
<WindowChromeRegion corners={appChromeLayout.contentCorners}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -476,6 +530,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
|
||||
pointerEvents="box-none"
|
||||
style={layoutStyles.windowSidebarToggle}
|
||||
>
|
||||
<WindowSidebarMenuToggle />
|
||||
</WindowChromeSafeArea>
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
<DownloadToast />
|
||||
@@ -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 (
|
||||
<SidebarModelProvider active={showSidebar && isOpen}>
|
||||
{showSidebar ? <LeftSidebar /> : null}
|
||||
<SidebarModelProvider active={active}>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
@@ -862,13 +931,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
boundaryMessageId?: string | null;
|
||||
boundaryCursor?: { epoch: string; seq: number } | null;
|
||||
itemCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> => {
|
||||
this.registeredWorkspaceBrowsers.push(input);
|
||||
};
|
||||
|
||||
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
|
||||
this.unregisteredWorkspaceBrowsers.push(browserId);
|
||||
};
|
||||
|
||||
public clearPartition = async (browserId: string): Promise<void> => {
|
||||
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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ScrollView>(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 ? (
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<ScrollView
|
||||
ref={desktopScrollRef}
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onLayout={desktopScrollbar.onLayout}
|
||||
onScroll={desktopScrollbar.onScroll}
|
||||
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!webScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{desktopScrollbar.overlay}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
|
||||
@@ -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 (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
testID="attachment-lightbox-image"
|
||||
source={imageSource}
|
||||
contentFit="contain"
|
||||
onError={handleImageError}
|
||||
style={imageFillStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
testID="attachment-lightbox-image"
|
||||
source={imageSource}
|
||||
contentFit="contain"
|
||||
onError={handleImageError}
|
||||
style={imageFillStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={closeButtonRowStyle}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, { width: number; height:
|
||||
|
||||
interface BrowserWebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
getWebContentsId(): number;
|
||||
}
|
||||
|
||||
interface BrowserWebviewIdentity {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BrowserWebviewProfileHost {
|
||||
profilePartition: string;
|
||||
registerAttachedBrowser(input: DesktopAttachedBrowserRegistration): Promise<void>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
117
packages/app/src/components/desktop-sidebar-layout.test.ts
Normal file
117
packages/app/src/components/desktop-sidebar-layout.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
95
packages/app/src/components/desktop-sidebar-layout.ts
Normal file
95
packages/app/src/components/desktop-sidebar-layout.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,6 @@ export function DraggableList<T>({
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface DraggableListProps<T> {
|
||||
ListHeaderComponent?: ReactElement | null;
|
||||
ListEmptyComponent?: ReactElement | null;
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
enableDesktopWebScrollbar?: boolean;
|
||||
/** When false, disables internal scrolling (use outer list to scroll). */
|
||||
scrollEnabled?: boolean;
|
||||
/**
|
||||
|
||||
@@ -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<T>({
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
extraData: _extraData,
|
||||
useDragHandle = false,
|
||||
@@ -147,11 +145,6 @@ export function DraggableList<T>({
|
||||
onDragEnd,
|
||||
onDragBegin,
|
||||
});
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
||||
enabled: showCustomScrollbar,
|
||||
});
|
||||
const pointerActivationConstraint = getPointerActivationConstraint(
|
||||
useDragHandle,
|
||||
POINTER_ACTIVATION_CONFIG,
|
||||
@@ -183,15 +176,10 @@ export function DraggableList<T>({
|
||||
<View style={wrapperStyle}>
|
||||
{scrollEnabled ? (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
onScroll={scrollbar.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
@@ -254,7 +242,6 @@ export function DraggableList<T>({
|
||||
{ListFooterComponent}
|
||||
</>
|
||||
)}
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): 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 (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={headerStyle} testID="explorer-header">
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={theme.spacing[2]}
|
||||
style={styles.header}
|
||||
testID="explorer-header"
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabsContainer}>
|
||||
{isGit && (
|
||||
@@ -370,13 +356,29 @@ function ExplorerSidebarContent({
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{isMobile && (
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
<X size={18} color={theme.colors.foregroundMuted} />
|
||||
{!hasRightWindowControls && (
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
testID="explorer-close"
|
||||
nativeID="explorer-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.explorer.close")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={18}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<FlatList<TreeRow>>(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<FlatList<TreeRow> | null>;
|
||||
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
|
||||
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => 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}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<RNScrollView>(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
|
||||
>
|
||||
<MarkdownRenderer text={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -318,11 +304,7 @@ function FilePreviewBody({
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||
@@ -331,14 +313,12 @@ function FilePreviewBody({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
{codeLines}
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
<RNImage
|
||||
source={imageSource ?? undefined}
|
||||
@@ -371,7 +347,6 @@ function FilePreviewBody({
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<FilePreviewBody
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { ScreenTitle } from "./screen-title";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
@@ -42,15 +43,18 @@ function MobileMenuIcon({ color }: { color: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarMenuToggle({
|
||||
style,
|
||||
function SidebarMenuToggleButton({
|
||||
isMobile,
|
||||
resolvedStyle,
|
||||
tooltipSide = "right",
|
||||
testID = "menu-button",
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
}: Omit<SidebarMenuToggleProps, "style"> & {
|
||||
isMobile: boolean;
|
||||
resolvedStyle: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
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 ? (
|
||||
<MobileMenuIcon color={menuIconColor} />
|
||||
) : (
|
||||
<PanelLeft size={theme.iconSize.md} color={menuIconColor} />
|
||||
)}
|
||||
{({ hovered, pressed }) => {
|
||||
const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return isMobile ? (
|
||||
<MobileMenuIcon color={color} />
|
||||
) : (
|
||||
<PanelLeft size={theme.iconSize.md} color={color} />
|
||||
);
|
||||
}}
|
||||
</HeaderToggleButton>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View pointerEvents="none" style={placeholderStyle}>
|
||||
<View style={styles.desktopMenuIconSpace} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
|
||||
}
|
||||
|
||||
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
|
||||
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
|
||||
return <SidebarMenuToggleButton {...props} isMobile={false} resolvedStyle={resolvedStyle} />;
|
||||
}
|
||||
|
||||
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
|
||||
return (
|
||||
<ScreenHeader
|
||||
@@ -107,6 +141,12 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
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,
|
||||
|
||||
@@ -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<ViewStyle>;
|
||||
rightStyle?: StyleProp<ViewStyle>;
|
||||
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 (
|
||||
<View style={styles.header}>
|
||||
<View style={innerStyle}>
|
||||
<View onLayout={onRowLayout} style={rowStyle}>
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={baseHorizontalPadding}
|
||||
onLayout={onRowLayout}
|
||||
style={rowStyle}
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<View style={leftCombinedStyle}>{left}</View>
|
||||
<View style={rightCombinedStyle}>{right}</View>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof useUnistyles>["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 (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
@@ -281,11 +272,11 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
active={active}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
@@ -525,6 +516,7 @@ function SidebarFooter({
|
||||
icon={Home}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
@@ -533,7 +525,6 @@ function SidebarFooter({
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarHelpMenu />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -603,6 +594,7 @@ function MobileSidebar({
|
||||
panelStyle={mobileSidebarInsetStyle}
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
@@ -628,23 +620,25 @@ function MobileSidebar({
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -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 (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<Animated.View
|
||||
accessibilityElementsHidden={!active}
|
||||
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
|
||||
pointerEvents={active ? "auto" : "none"}
|
||||
style={desktopSidebarStyle}
|
||||
>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
{ownsTopLeft ? (
|
||||
<View style={styles.desktopChromeRow}>
|
||||
<TitlebarDragRegion />
|
||||
</View>
|
||||
) : (
|
||||
<TitlebarDragRegion />
|
||||
)}
|
||||
<View style={sidebarHeaderGroupStyle}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
@@ -910,6 +919,9 @@ const staticStyles = RNStyleSheet.create({
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
desktopSidebarHidden: {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -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",
|
||||
|
||||
@@ -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> | void;
|
||||
onFork?: (target: AssistantForkTarget) => Promise<void> | 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 (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
@@ -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 (
|
||||
<ThemedChevronRightIcon size={12} style={chevronStyle} uniProps={foregroundColorMapping} />
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevronRightIcon size={12} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
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 ? (
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
style={expandableBadgeStylesheet.detailWrapper}
|
||||
style={detailWrapperStyle}
|
||||
onHoverIn={handleDetailHoverIn}
|
||||
onHoverOut={handleDetailHoverOut}
|
||||
>
|
||||
@@ -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({
|
||||
<ToolCallDetailsContent
|
||||
detail={effectiveDetail}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={400}
|
||||
maxHeight={maxDetailHeight}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [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;
|
||||
}
|
||||
|
||||
@@ -432,7 +432,6 @@ function OpenScheduleFormSheet({
|
||||
onClose={onClose}
|
||||
onDismiss={onDismiss}
|
||||
footer={footer}
|
||||
webScrollbar
|
||||
testID="schedule-form-sheet"
|
||||
>
|
||||
<ScheduleFormFields
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Activity, CircleHelp, Keyboard } from "lucide-react-native";
|
||||
import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { DiscordIcon } from "@/components/icons/discord-icon";
|
||||
@@ -18,16 +18,21 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
const ThemedActivity = withUnistyles(Activity);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
const ThemedKeyboard = withUnistyles(Keyboard);
|
||||
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
@@ -47,6 +52,29 @@ const discordLeadingIcon = (
|
||||
const githubLeadingIcon = (
|
||||
<ThemedGitHubIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const changelogLeadingIcon = (
|
||||
<ThemedGift size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
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 (
|
||||
<DropdownMenuHint
|
||||
style={styles.versionHint}
|
||||
testID={`sidebar-help-host-version-${host.serverId}`}
|
||||
>
|
||||
{host.label} {version}
|
||||
</DropdownMenuHint>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
@@ -94,30 +127,34 @@ export function SidebarHelpMenu() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<DropdownMenuLabel>{t("sidebar.help.troubleshoot")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-diagnostics"
|
||||
description={t("sidebar.help.diagnosticsDescription")}
|
||||
leading={diagnosticLeadingIcon}
|
||||
onSelect={openAppDiagnostic}
|
||||
>
|
||||
{t("sidebar.help.diagnostics")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{showKeyboardShortcuts ? (
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
description={t("sidebar.help.shortcutsDescription")}
|
||||
leading={shortcutsLeadingIcon}
|
||||
onSelect={openKeyboardShortcuts}
|
||||
>
|
||||
{t("sidebar.help.shortcuts")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-changelog"
|
||||
leading={changelogLeadingIcon}
|
||||
onSelect={openChangelog}
|
||||
>
|
||||
{t("sidebar.help.whatsNew")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-diagnostics"
|
||||
leading={diagnosticLeadingIcon}
|
||||
onSelect={openAppDiagnostic}
|
||||
>
|
||||
{t("sidebar.help.diagnostics")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-discord"
|
||||
description={t("sidebar.help.discordDescription")}
|
||||
leading={discordLeadingIcon}
|
||||
onSelect={openDiscord}
|
||||
>
|
||||
@@ -125,16 +162,20 @@ export function SidebarHelpMenu() {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-github"
|
||||
description={t("sidebar.help.githubDescription")}
|
||||
leading={githubLeadingIcon}
|
||||
onSelect={openGitHubIssue}
|
||||
>
|
||||
{t("sidebar.help.github")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuHint testID="sidebar-help-version">
|
||||
{t("sidebar.help.version", { version })}
|
||||
</DropdownMenuHint>
|
||||
<View style={styles.versionList}>
|
||||
<DropdownMenuHint style={styles.versionHint} testID="sidebar-help-version">
|
||||
{t("sidebar.help.version", { version })}
|
||||
</DropdownMenuHint>
|
||||
{hosts.map((host) => (
|
||||
<HostVersionHint key={host.serverId} host={host} />
|
||||
))}
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
37
packages/app/src/components/split-container-focus.test.ts
Normal file
37
packages/app/src/components/split-container-focus.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
21
packages/app/src/components/split-container-focus.ts
Normal file
21
packages/app/src/components/split-container-focus.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<SplitContainerProps, "layout" | "onMov
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
windowChromeCorners: WindowChromeCorners;
|
||||
}
|
||||
|
||||
interface SplitPaneViewProps extends Omit<
|
||||
@@ -169,6 +176,7 @@ interface SplitPaneViewProps extends Omit<
|
||||
| "showDropZones"
|
||||
| "dropPreview"
|
||||
| "onResizeSplit"
|
||||
| "windowChromeCorners"
|
||||
> {
|
||||
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<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(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 && <WindowChromeSafeArea placement="below" />}
|
||||
<SplitNodeView
|
||||
node={renderRoot}
|
||||
workspaceKey={workspaceKey}
|
||||
@@ -601,6 +610,7 @@ export function SplitContainer({
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{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 (
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
<WindowChromeRegion corners={windowChromeCorners}>
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -843,6 +856,7 @@ function SplitNodeView({
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={windowChromeCorners}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
@@ -898,7 +912,6 @@ function SplitPaneView({
|
||||
const { theme: _theme } = useUnistyles();
|
||||
const paneRef = useRef<View | null>(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 (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
||||
<View style={paneTabsStyle}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
@@ -1035,7 +1044,7 @@ function SplitPaneView({
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{mountedPaneTabIds.length > 0
|
||||
|
||||
@@ -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<string> = [
|
||||
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<HTMLElement | null>(null);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastObservedOffsetRef = useRef<number | null>(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<ViewportMetrics>({
|
||||
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<ReturnType<typeof setTimeout> | 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<DOMImperativeFactory | null>(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<HTMLElement>(".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<HTMLElement>(".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<CSSProperties>(
|
||||
() => ({
|
||||
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<CSSProperties>(
|
||||
() => ({
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -996,18 +652,6 @@ export default function TerminalEmulator({
|
||||
>
|
||||
<div ref={hostRef} style={HOST_DIV_STYLE} />
|
||||
<div style={dropOverlayStyle} />
|
||||
{scrollbarGeometry.isVisible ? (
|
||||
<div style={SCROLLBAR_CONTAINER_STYLE}>
|
||||
<div
|
||||
style={handleContainerStyle}
|
||||
onPointerDown={handleScrollbarPointerDown}
|
||||
onPointerEnter={handleScrollbarPointerEnter}
|
||||
onPointerLeave={handleScrollbarPointerLeave}
|
||||
>
|
||||
<div style={handleInnerStyle} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ViewStyle>;
|
||||
fullBleedContainerStyle: StyleProp<ViewStyle>;
|
||||
loadingContainerStyle: StyleProp<ViewStyle>;
|
||||
webScrollbarStyle: StyleProp<ViewStyle>;
|
||||
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}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
@@ -224,7 +210,6 @@ function WorktreeSetupDetailSection({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
@@ -389,7 +374,6 @@ function SubAgentDetailSection({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
@@ -466,12 +450,7 @@ function ScrollableTextSection({
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
||||
{keyedLines ? (
|
||||
<HighlightedLines lines={keyedLines} startLine={startLine} />
|
||||
) : (
|
||||
@@ -501,12 +480,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{result ? `${url}\n\n${result}` : url}
|
||||
</Text>
|
||||
@@ -545,12 +519,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.categoryStatus}>
|
||||
<TriangleAlert size={12} color={styles.error.color} />
|
||||
<Text style={styles.error}>
|
||||
{t("toolCallGroup.failed", { count: category.failedCount })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (category.runningCount > 0) {
|
||||
return <ActivityIndicator size={12} color={styles.muted.color} />;
|
||||
}
|
||||
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 (
|
||||
<View style={styles.categoryRow}>
|
||||
<View style={styles.categoryIcon}>
|
||||
<Icon size={12} color={styles.muted.color} />
|
||||
</View>
|
||||
<Text style={styles.categoryCount}>×{category.callCount}</Text>
|
||||
<Text style={styles.categoryLabel}>{category.label}</Text>
|
||||
<CategoryStatus category={category} />
|
||||
{resourceText ? (
|
||||
<Text style={styles.resources} numberOfLines={1}>
|
||||
{resourceText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupHeaderIcon({
|
||||
group,
|
||||
compact,
|
||||
}: {
|
||||
group: CompactToolCallGroupModel;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const size = compact ? 11 : 12;
|
||||
if (group.failedCount > 0) {
|
||||
return <TriangleAlert size={size} color={styles.error.color} />;
|
||||
}
|
||||
if (group.isRunning) {
|
||||
return <ActivityIndicator size={size} color={styles.foreground.color} />;
|
||||
}
|
||||
return <Wrench size={size} color={styles.muted.color} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.container} testID="tool-call-group">
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={headerStyle}
|
||||
>
|
||||
<View style={styles.headerIcon}>
|
||||
<GroupHeaderIcon group={group} compact={isOverview} />
|
||||
</View>
|
||||
{isOverview ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.conciseTitle}>{t("toolCallGroup.title")}</Text>
|
||||
<Text style={styles.conciseCallCount}>×{group.callCount}</Text>
|
||||
</>
|
||||
)}
|
||||
{group.failedCount > 0 ? (
|
||||
<Text style={styles.error}>
|
||||
{t("toolCallGroup.failed", { count: group.failedCount })}
|
||||
</Text>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
size={isOverview ? 12 : 14}
|
||||
color={styles.muted.color}
|
||||
style={expanded ? styles.chevronExpanded : undefined}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{expanded ? <View style={styles.expandedCalls}>{children}</View> : null}
|
||||
{!expanded && !isOverview ? (
|
||||
<View style={styles.categories}>
|
||||
{group.categories.map((category) => (
|
||||
<CategoryRow key={category.key} category={category} resourceLimit={resourceLimit} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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({
|
||||
<FloatingScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(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 (
|
||||
<View style={styles.hintContainer} testID={testID}>
|
||||
<View style={hintContainerStyle} testID={testID}>
|
||||
<Text style={styles.hintText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLElement | null>,
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
contentRef?: RefObject<HTMLElement | null>;
|
||||
},
|
||||
): ReactNode {
|
||||
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||
const contentRef = options?.contentRef;
|
||||
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
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 (
|
||||
<WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<NativeScrollEvent>) => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
onContentSizeChange: (width: number, height: number) => void;
|
||||
overlay: ReactNode;
|
||||
}
|
||||
|
||||
export function useWebScrollViewScrollbar(
|
||||
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||
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 ? (
|
||||
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
|
||||
) : null;
|
||||
|
||||
return {
|
||||
onScroll: metricsHook.onScroll,
|
||||
onLayout: metricsHook.onLayout,
|
||||
onContentSizeChange: metricsHook.onContentSizeChange,
|
||||
overlay,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => {
|
||||
setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next));
|
||||
}, []);
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastObservedOffsetRef = useRef<number | null>(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 (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<View
|
||||
style={thumbRegionStyle}
|
||||
pointerEvents={handleVisible ? "auto" : "none"}
|
||||
{...(panResponder?.panHandlers ?? {})}
|
||||
{...(platformIsWeb
|
||||
? ({
|
||||
onPointerDown: startWebDrag,
|
||||
onMouseEnter: handleGrabHoverIn,
|
||||
onMouseLeave: handleGrabHoverOut,
|
||||
} as object)
|
||||
: null)}
|
||||
>
|
||||
<View style={handleStyle} pointerEvents="none" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<unknown> } | null | undefined;
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isAgentRunning: boolean;
|
||||
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
|
||||
voiceAgentId: string | undefined;
|
||||
}
|
||||
|
||||
async function stopRealtimeVoiceImpl(ctx: StopRealtimeVoiceContext): Promise<void> {
|
||||
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
}
|
||||
}, [getWebTextArea]);
|
||||
|
||||
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
||||
enabled: isWeb,
|
||||
});
|
||||
|
||||
usePasteImagesEffect({
|
||||
getWebTextArea,
|
||||
isConnected,
|
||||
@@ -1826,6 +1808,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<ThemedTextInput
|
||||
ref={textInputRef}
|
||||
dataSet={COMPOSER_INPUT_DATASET}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
@@ -1842,7 +1825,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
{inputScrollbar}
|
||||
<FocusHint
|
||||
visible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
computeCanStartDictation,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
stopRealtimeVoice,
|
||||
} from "./state";
|
||||
|
||||
const connected = { isConnected: true } as never;
|
||||
const disconnected = { isConnected: false } as never;
|
||||
@@ -149,3 +154,45 @@ describe("composer send behavior", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,14 @@ import type { MessagePayload } from "@/composer/types";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
|
||||
interface StopRealtimeVoiceContext {
|
||||
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isAgentRunning: boolean;
|
||||
client: { cancelAgent: (agentId: string) => Promise<unknown> } | 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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -476,6 +476,15 @@ function applyToolErrorToMessages(
|
||||
);
|
||||
}
|
||||
|
||||
function notifyVoiceAbortFailure(
|
||||
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["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,
|
||||
]);
|
||||
|
||||
80
packages/app/src/desktop/components/browser-data-section.tsx
Normal file
80
packages/app/src/desktop/components/browser-data-section.tsx
Normal file
@@ -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 (
|
||||
<SettingsSection title={t("settings.general.browserData.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.general.browserData.siteData")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.browserData.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
loading={isClearing}
|
||||
disabled={isClearing}
|
||||
onPress={handleClear}
|
||||
>
|
||||
{clearButtonLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export interface DesktopWindowControlsOverlayUpdate {
|
||||
export interface DesktopWindowBridge {
|
||||
label?: string;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
setFullscreen?: (fullscreen: boolean) => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
@@ -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<void>;
|
||||
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
||||
readonly profilePartition?: string;
|
||||
registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise<void>;
|
||||
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
|
||||
setWorkspaceActiveBrowser?: (input: {
|
||||
workspaceId: string;
|
||||
browserId: string | null;
|
||||
}) => Promise<void>;
|
||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||
clearPartition?: (browserId: string) => Promise<void>;
|
||||
clearProfile?: (legacyBrowserIds: string[]) => Promise<void>;
|
||||
executeAutomationCommand?: (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
) => Promise<BrowserAutomationExecuteResponse["payload"]>;
|
||||
|
||||
@@ -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<FlatList<DiffFlatItem> | null>;
|
||||
handleDiffListLayout: (event: LayoutChangeEvent) => void;
|
||||
handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => 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<Record<string, number>>({});
|
||||
@@ -2089,25 +2079,17 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
[getBodyHeightKey],
|
||||
);
|
||||
|
||||
const handleDiffListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
scrollbar.onScroll(event);
|
||||
},
|
||||
[scrollbar],
|
||||
);
|
||||
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
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 ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||
|
||||
<View style={styles.diffContainer}>
|
||||
{bodyContent}
|
||||
{hasChanges ? scrollbar.overlay : null}
|
||||
</View>
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
||||
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>([
|
||||
"overview",
|
||||
"concise",
|
||||
"detailed",
|
||||
]);
|
||||
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./use-web-scrollbar-style.web";
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { ViewStyle } from "react-native";
|
||||
|
||||
export function useWebScrollbarStyle(): ViewStyle | undefined {
|
||||
return undefined;
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
||||
{children}
|
||||
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<Composer
|
||||
|
||||
@@ -57,7 +57,7 @@ import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
@@ -70,6 +70,7 @@ import { CommunityLinks } from "@/components/community-links";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { BrowserDataSection } from "@/desktop/components/browser-data-section";
|
||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
@@ -97,13 +98,12 @@ import {
|
||||
} from "@/screens/settings/host-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
type EnableBuiltInDaemonOption,
|
||||
useEnableBuiltInDaemonOption,
|
||||
} from "@/desktop/hooks/use-enable-built-in-daemon-option";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
@@ -993,7 +993,6 @@ function SettingsSidebar({
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !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({
|
||||
<View style={innerContainerStyle}>
|
||||
<View style={sidebarStyles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopStyle} /> : null}
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarHeaderRow
|
||||
icon={ArrowLeft}
|
||||
label={t("settings.backToWorkspace")}
|
||||
@@ -1134,11 +1132,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insetBottomStyle = useMemo(() => ({ 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 (
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleLanguageChange={handleLanguageChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
<>
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleLanguageChange={handleLanguageChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
{isDesktopApp ? <BrowserDataSection /> : null}
|
||||
</>
|
||||
);
|
||||
case "appearance":
|
||||
return <AppearanceSection />;
|
||||
@@ -1440,6 +1436,16 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
);
|
||||
}
|
||||
|
||||
const desktopDetailHeaderLeft = detailHeader ? (
|
||||
<>
|
||||
<HeaderIconBadge>
|
||||
<detailHeader.Icon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</HeaderIconBadge>
|
||||
<ScreenTitle testID="settings-detail-header-title">{detailHeader.title}</ScreenTitle>
|
||||
{detailHeader.titleAccessory}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const addHostModals = (
|
||||
<>
|
||||
<AddHostMethodModal
|
||||
@@ -1469,7 +1475,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={t("settings.title")} onBack={handleBackToWorkspace} />
|
||||
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
@@ -1500,7 +1506,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
titleAccessory={detailHeader?.titleAccessory}
|
||||
onBack={detailBackHandler}
|
||||
/>
|
||||
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
{addHostModals}
|
||||
@@ -1514,43 +1520,31 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={desktopStyles.row}>
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHostSection={handleSelectHostSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
activeHostServerId={activeHostServerId}
|
||||
layout="desktop"
|
||||
/>
|
||||
<View style={desktopStyles.contentPane}>
|
||||
<ScreenHeader
|
||||
borderless={!detailHeader}
|
||||
windowControlsPaddingRole="detailHeader"
|
||||
left={
|
||||
detailHeader ? (
|
||||
<>
|
||||
<HeaderIconBadge>
|
||||
<detailHeader.Icon
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</HeaderIconBadge>
|
||||
<ScreenTitle testID="settings-detail-header-title">
|
||||
{detailHeader.title}
|
||||
</ScreenTitle>
|
||||
{detailHeader.titleAccessory}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHostSection={handleSelectHostSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
activeHostServerId={activeHostServerId}
|
||||
layout="desktop"
|
||||
/>
|
||||
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||
<ScreenHeader
|
||||
borderless={!detailHeader}
|
||||
left={desktopDetailHeaderLeft}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
</View>
|
||||
{addHostModals}
|
||||
</View>
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<DesktopDaemonLogs | null>(null);
|
||||
const [logsError, setLogsError] = useState<string | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
@@ -404,7 +394,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
<View style={styles.errorScreen}>
|
||||
<TitlebarDragRegion />
|
||||
<ScrollView
|
||||
style={errorScrollViewStyle}
|
||||
style={styles.errorScrollView}
|
||||
contentContainerStyle={styles.errorScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
@@ -424,7 +414,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
|
||||
<View style={styles.logsContainer}>
|
||||
<ScrollView
|
||||
style={logsScrollStyle}
|
||||
style={styles.logsScroll}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
|
||||
@@ -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<typeof ExplorerSidebar>,
|
||||
"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 (
|
||||
<View style={styles.threePaneRow}>
|
||||
<WindowChromeRegion corners={explorerRendered ? "top-left" : "both"}>
|
||||
<FloatingPanelPortalHostNameProvider hostName={portalHostName}>
|
||||
{children}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
</WindowChromeRegion>
|
||||
|
||||
<FloatingPanelPortalHost name={portalHostName} />
|
||||
|
||||
{showExplorerSidebar && workspaceRoot ? (
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<ExplorerSidebar {...explorerProps} workspaceRoot={workspaceRoot} />
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<View style={styles.threePaneRow}>
|
||||
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
|
||||
{workspaceCenterColumn}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
|
||||
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
|
||||
|
||||
{showExplorerSidebar && workspaceDirectory ? (
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<WorkspaceChromeRow
|
||||
portalHostName={workspaceFloatingPanelPortalHostName}
|
||||
showExplorerSidebar={showExplorerSidebar}
|
||||
explorerOpen={isExplorerOpen}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
>
|
||||
{workspaceCenterColumn}
|
||||
</WorkspaceChromeRow>
|
||||
<ImportSessionSheet
|
||||
visible={isImportSheetVisible}
|
||||
client={client}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type DraftStoreState,
|
||||
} from "./state";
|
||||
import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration";
|
||||
import { createDraftPersistStorage } from "./persistence";
|
||||
|
||||
export type { DraftInput, DraftLifecycleState } from "./state";
|
||||
|
||||
@@ -49,6 +50,13 @@ type DraftStore = DraftStoreState & DraftStoreActions;
|
||||
|
||||
const draftGenerations = new Map<string, number>();
|
||||
let gcScheduled = false;
|
||||
const draftPersistStorage = createDraftPersistStorage(
|
||||
createJSONStorage<DraftStoreState>(() => AsyncStorage),
|
||||
);
|
||||
|
||||
export function flushDraftPersistStorage(): Promise<void> {
|
||||
return draftPersistStorage?.flush() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
function createDraftRecord(input: {
|
||||
draft: DraftInput;
|
||||
@@ -378,7 +386,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
{
|
||||
name: "paseo-drafts",
|
||||
version: DRAFT_STORE_VERSION,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
storage: draftPersistStorage,
|
||||
migrate: (persistedState) => {
|
||||
return migratePersistedState(persistedState, {
|
||||
migrateLegacyImages,
|
||||
|
||||
109
packages/app/src/stores/draft-store/persistence.test.ts
Normal file
109
packages/app/src/stores/draft-store/persistence.test.ts
Normal file
@@ -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<DraftState> | null = null;
|
||||
let scheduled: { callback: () => void; dueAt: number } | null = null;
|
||||
const storage: PersistStorage<DraftState> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
82
packages/app/src/stores/draft-store/persistence.ts
Normal file
82
packages/app/src/stores/draft-store/persistence.ts
Normal file
@@ -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<T> extends PersistStorage<T> {
|
||||
flush: () => Promise<void>;
|
||||
}
|
||||
|
||||
const systemScheduler: PersistenceScheduler = {
|
||||
now: Date.now,
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
};
|
||||
|
||||
export function createDraftPersistStorage<T>(
|
||||
storage: PersistStorage<T>,
|
||||
scheduler?: PersistenceScheduler,
|
||||
): DraftPersistStorage<T>;
|
||||
export function createDraftPersistStorage<T>(
|
||||
storage: PersistStorage<T> | undefined,
|
||||
scheduler?: PersistenceScheduler,
|
||||
): DraftPersistStorage<T> | undefined;
|
||||
export function createDraftPersistStorage<T>(
|
||||
storage: PersistStorage<T> | undefined,
|
||||
scheduler: PersistenceScheduler = systemScheduler,
|
||||
): DraftPersistStorage<T> | undefined {
|
||||
if (!storage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let pending: { name: string; value: Parameters<typeof storage.setItem>[1] } | null = null;
|
||||
let timer: unknown = null;
|
||||
let lastWriteAt = -Infinity;
|
||||
|
||||
const cancelTimer = () => {
|
||||
if (timer !== null) {
|
||||
scheduler.cancel(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const flush = async (): Promise<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
3
packages/app/src/styles/install-web-scrollbar-styles.ts
Normal file
3
packages/app/src/styles/install-web-scrollbar-styles.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function installWebScrollbarStyles(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
47
packages/app/src/styles/install-web-scrollbar-styles.web.ts
Normal file
47
packages/app/src/styles/install-web-scrollbar-styles.web.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -20,7 +20,7 @@ StyleSheet.configure({
|
||||
breakpoints: {
|
||||
xs: 0,
|
||||
sm: 576,
|
||||
md: 768,
|
||||
md: 720,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
},
|
||||
|
||||
10
packages/app/src/styles/web-scrollbar.ts
Normal file
10
packages/app/src/styles/web-scrollbar.ts
Normal file
@@ -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`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user