mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d369c9d36b | ||
|
|
12890f8ab0 | ||
|
|
7c4aa058ff | ||
|
|
702bc942b7 | ||
|
|
b1b5010bd2 | ||
|
|
888b54bb44 | ||
|
|
63c3bb8c42 | ||
|
|
569b34263d | ||
|
|
71cc853f7b | ||
|
|
f2dfa83e83 | ||
|
|
fb4ca20dbd | ||
|
|
4e2aa97a74 | ||
|
|
5ab9b8dcd4 | ||
|
|
4284209025 | ||
|
|
6ae87ec7e2 | ||
|
|
630e8301ad | ||
|
|
74bb3e76e4 | ||
|
|
3081b99b06 | ||
|
|
adf27de7d0 | ||
|
|
ab6cdb7c89 | ||
|
|
0d61cca50c | ||
|
|
0eee4fd9b0 | ||
|
|
69ef741a73 | ||
|
|
90f3cab585 | ||
|
|
fc775b6db5 | ||
|
|
71e7cf6ff8 | ||
|
|
eda3e4318f | ||
|
|
91154ba4e8 | ||
|
|
05920a236c |
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
|
||||
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
Daemon-owned outbound connections and routed API scope ownership are described in
|
||||
[Connected hosts](connected-hosts.md).
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Connected hosts
|
||||
|
||||
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
|
||||
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
|
||||
`registrationId` created before B is reachable.
|
||||
|
||||
## Ownership and authority
|
||||
|
||||
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
|
||||
target description. Replacement inputs never come back through a read API. Keep this file private
|
||||
when collecting diagnostics or backing up daemon state.
|
||||
|
||||
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
|
||||
including a user's disabled state. Changed input requires an explicit update. Display labels and
|
||||
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
|
||||
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
|
||||
Disabling retains the opt-out.
|
||||
|
||||
Each enabled registration owns one physical daemon link, shared by status monitoring and every
|
||||
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
|
||||
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
|
||||
physical link principal, then intersects forwarded caller grants with that link's current authority.
|
||||
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
|
||||
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
|
||||
|
||||
A producer that registers a host through a routed API is qualified on B as
|
||||
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
|
||||
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
|
||||
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
|
||||
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
|
||||
|
||||
A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). The first child snapshot carries the owner's ledger projection even while creation is pending, because tab placement runs before creation finishes. Explicit clearing survives list and disk-cache round trips; a parent pointer or label cannot recreate membership on a capable daemon.
|
||||
|
||||
Retained app conversations bind their stores, queries, initialization and layout to origin authority as well as destination. Two routes to B therefore keep separate cached evidence and command capabilities. Provider catalog pushes belong to that same destination binding, including facts shown in the parent's track. Route restore remembers an origin ID, then reacquires current authority; persisted namespaces grant no access.
|
||||
|
||||
Durable relationships retain a stable authority reference and creation ceiling, never live credentials or execution capabilities. Recovery must resolve current authority through its owner. Derived runtime sessions cannot authorize their own recovery. Execution-scoped admissions and non-owner durable references without a supported authority resolver fail explicitly; do not recover them through the daemon owner's MCP runtime.
|
||||
|
||||
Before accepting new writes, the daemon adopts eligible pre-feature local parent labels into authoritative membership. The upgrade records its bounded candidates before qualifying agent metadata, then resumes those candidates after interruption. It never rescans later label edits. Existing membership, including forgotten entries, takes precedence; explicit null or qualified parent pointers do not establish legacy provenance. Adopted entries preserve the old daemon-owned local lifecycle, not an authenticated creator grant, and contain no creation recipe to replay. Recovery still resolves current owner authority within the retained self-only ceiling.
|
||||
|
||||
Provider-owned subagents are descriptors and timelines in `ProviderSubagentStore`, not `AgentStorage` records. They retain their provider observation/cancellation path. Internal managed agents and execution-owned records are separate stored/runtime concepts and are excluded from adoption on both sides of the relationship.
|
||||
|
||||
## Connections and recovery
|
||||
|
||||
The shared Node connector owns the target grammar and each connection attempt, including a fresh
|
||||
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
|
||||
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
|
||||
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
|
||||
registrations remain inspectable; a changed destination requires explicit identity replacement.
|
||||
|
||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||
operations; requesting this feature gives update guidance.
|
||||
|
||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||
through the usual owned-subscription contract. The ordinary client queues new requests while connecting
|
||||
within its existing deadline and rejects requests while disconnected. Connection loss clears old
|
||||
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
|
||||
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
|
||||
before retrying a mutation.
|
||||
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
|
||||
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
|
||||
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
|
||||
|
||||
Creation cancellation fences a reserved creation identity before admission. An admitted receipt can precede resource readiness and cannot be reported as canceled. Observe its current receipt and later changes through the ordinary SDK instead of replaying an uncertain prompt. Parent archive retains a pending obligation until cancellation or destination lifecycle confirmation; explicit forget removes local membership without claiming the destination changed.
|
||||
|
||||
Run journals retain submission and provider-turn identity. Restarted nonterminal evidence is interrupted, and subsequent evidence must match that turn. Idle and reconnect do not prove completion. A storage failure retains observed evidence with pending durability; a later read retries persistence. Notification recovery must preserve checkpoint identity and this durability distinction.
|
||||
|
||||
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
|
||||
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
|
||||
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
|
||||
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
|
||||
existing frame limit.
|
||||
|
||||
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
|
||||
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
|
||||
frame size. Awaitable output completes when the physical send completes. An overflowing channel
|
||||
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
|
||||
subscription demand is rebuilt by its owning client on reconnect.
|
||||
|
||||
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
|
||||
[CLI reference](../public-docs/cli.md#connected-hosts), and
|
||||
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
|
||||
+42
-16
@@ -11,7 +11,7 @@ contracts.
|
||||
|
||||
## Panel host contract
|
||||
|
||||
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
Every panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
@@ -21,7 +21,10 @@ so a pane’s + menu omits each while that pane already contains it. Closing one
|
||||
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
|
||||
can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
retention. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and
|
||||
close-gesture lifetime. Platform adapters own resizing and inset geometry.
|
||||
The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget
|
||||
can remount. Native retained content stays live, while web chat uses its existing deferred Freeze.
|
||||
|
||||
## Sheets
|
||||
|
||||
@@ -40,11 +43,11 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
|
||||
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
|
||||
@@ -56,12 +59,16 @@ the sheet's tab into a pane, and edits the autosave has not started writing are
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
One Explorer host stays beside the center column across compact overlays and docks. Keep its
|
||||
providers and gesture roots at the same React position when width or appearance changes. The
|
||||
workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves
|
||||
subscribe to theme changes; the file editor reconfigures its existing editor theme compartment.
|
||||
Ordinary leaf routes retain the stack appearance policy.
|
||||
The shell is outside the workspace split canvas so it divides the full workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
never read or modify that width.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
opens. Compact and wide native layouts select the matching workspace tab in Explorer. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
|
||||
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
|
||||
@@ -76,18 +83,37 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
|
||||
|
||||
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
|
||||
hide their close buttons through the panel manifest; close them from the tab context menu.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
|
||||
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
|
||||
Web also supports dragging agents and terminals into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
|
||||
does not change workspace focus.
|
||||
does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts
|
||||
accept only reordering inside the source pane.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
|
||||
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
|
||||
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
|
||||
lifecycle.
|
||||
Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same
|
||||
workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer
|
||||
overlay; at 600 points and above it reserves width beside the workspace. Native docking keeps its
|
||||
touch resize handle. Full workspace splits remain a separate web-only capability.
|
||||
|
||||
The dock reserves at least 240 points and the workspace 360. App navigation yields when it cannot
|
||||
share the remaining width with both. This keeps a portrait tablet from rendering three unusably
|
||||
narrow columns; widening restores navigation without changing its saved open state.
|
||||
|
||||
Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and
|
||||
contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing
|
||||
a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use
|
||||
the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet
|
||||
keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the
|
||||
actual destination before revealing it, including deduplicated targets.
|
||||
|
||||
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
|
||||
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
|
||||
up to ten with its existing inactive TTL.
|
||||
|
||||
PR discovery belongs to the active workspace: split layouts discover while active, and other
|
||||
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
|
||||
content also requests timeline data. Inactive consumers read cached data without request demand.
|
||||
Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out.
|
||||
|
||||
## Side pane
|
||||
|
||||
|
||||
+3
-1
@@ -157,7 +157,9 @@ a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
Navigators keep their identity across appearance changes. `ThemedStack` remounts
|
||||
each screen's content below the native stack when appearance tokens change; a
|
||||
each ordinary screen's content below the native stack when appearance tokens change.
|
||||
Workspace screens own retained panel lifetimes and update appearance at their
|
||||
leaves through `screenOwnedAppearance` ([Explorer](explorer-sidebar.md)). A
|
||||
keyed wrapper above a stack remounts the stack itself, and Android crashes when
|
||||
that happens while a FragmentManager transaction is running, which settings
|
||||
hydration at startup makes likely. A screen that mounts a nested navigator (the
|
||||
|
||||
@@ -32,7 +32,9 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
|
||||
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
|
||||
Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
|
||||
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
|
||||
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
|
||||
see [Connected hosts](connected-hosts.md#ownership-and-authority).
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
|
||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures and the standalone Electron browser runner share the developer-toast dismissal helper while retaining console logs. A pointer click moves hover away from the tested control and stalls behind app modal backdrops.
|
||||
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||
|
||||
@@ -45,6 +45,21 @@
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
packages = self.packages.${system};
|
||||
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
|
||||
in
|
||||
{
|
||||
license-metadata =
|
||||
assert packages.paseo.meta.license.spdxId == license;
|
||||
assert packages.desktop.meta.license.spdxId == license;
|
||||
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
|
||||
@@ -257,7 +257,7 @@ buildNpmPackage {
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
license = lib.licenses.asl20;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
|
||||
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ buildNpmPackage rec {
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
license = lib.licenses.asl20;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
Generated
+6650
-3440
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -137,16 +137,21 @@
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"react-dom": "19.2.3",
|
||||
"typescript": "^5.9.3",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"expo": "~57.0.30",
|
||||
"expo-router": "~57.0.28",
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-worklets": "0.8.3"
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-worklets": "0.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ export default {
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
ios: { enableSceneSupport: true },
|
||||
android: {
|
||||
minSdkVersion: 29,
|
||||
kotlinVersion: "2.1.20",
|
||||
|
||||
@@ -2,48 +2,56 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
for (const width of [1400, 390]) {
|
||||
test(`adds a changed file to the focused chat without replacing its composer draft at ${width}px`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
if (width < 600) await openChangesTreePanel(page);
|
||||
else await openChangesPanel(page);
|
||||
const changedFile = page.getByTestId("diff-file-0-toggle");
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
if (width < 600)
|
||||
await expect(
|
||||
page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
if (width >= 600) await expect(agentComposer).toBeFocused();
|
||||
else await expect(agentComposer).toBeEditable();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -833,13 +833,15 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
const compactChangesTab = page
|
||||
.getByTestId("workspace-tab-changes_tree")
|
||||
.filter({ visible: true });
|
||||
if (!(await compactChangesTab.isVisible())) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
}
|
||||
await expect(compactChangesTab).toBeVisible();
|
||||
await compactChangesTab.click();
|
||||
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
const compactExplorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
|
||||
|
||||
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
|
||||
@@ -876,6 +878,27 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("compact Explorer renders inline hunks with a saved tree-only desktop preference", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await page.addInitScript(
|
||||
({ key }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ inlineDiff: false, layout: "split" }));
|
||||
},
|
||||
{ key: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
|
||||
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(BEFORE);
|
||||
});
|
||||
|
||||
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1674,10 +1697,10 @@ async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promis
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
@@ -45,10 +45,10 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
|
||||
|
||||
@@ -125,7 +125,7 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
|
||||
});
|
||||
|
||||
const closeExplorer = page
|
||||
.getByTestId("explorer-header")
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByRole("button", { name: "Close Explorer sidebar" });
|
||||
await expect(closeExplorer).not.toBeInViewport();
|
||||
|
||||
@@ -809,7 +809,7 @@ async function expectUnmatchedFeedback(page: Page) {
|
||||
}
|
||||
|
||||
function compactReviewSurface(page: Page) {
|
||||
return page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
async function openCompactReviewExplorer(
|
||||
page: Page,
|
||||
@@ -847,11 +847,15 @@ async function scrollReviewToLastLine(page: Page) {
|
||||
|
||||
async function revealCompactChanges(page: Page) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByTestId("workspace-tab-changes_tree")
|
||||
.click();
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
async function expectReviewEditorBody(page: Page, body: string) {
|
||||
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
|
||||
body,
|
||||
);
|
||||
const editor = page.getByRole("textbox", { name: "Review comment", exact: true });
|
||||
// A viewport resize starts the presentation handoff; exactly one editor must remain accessible.
|
||||
await expect(editor).toHaveCount(1);
|
||||
await expect(editor).toHaveValue(body);
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ for (const layout of [
|
||||
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
||||
const account = usageCard(details, "login-journey:account");
|
||||
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
||||
await layout.close(page);
|
||||
});
|
||||
@@ -161,7 +161,7 @@ for (const layout of [
|
||||
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
||||
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { installDeveloperToastDismissal } from "../support/developer-ui.mjs";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
|
||||
await page.evaluate((useShadow) => {
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "error-toast";
|
||||
document.body.append(toast);
|
||||
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
|
||||
const button = document.createElement("button");
|
||||
button.style.cssText = "width:32px;height:32px";
|
||||
button.addEventListener("click", () => button.remove());
|
||||
root.append(button);
|
||||
}, shadow);
|
||||
}
|
||||
|
||||
async function openTransientDeveloperToast(page: Page) {
|
||||
await page.setContent("<button>Continue</button>");
|
||||
await showTransientDeveloperToast(page, false);
|
||||
await installDeveloperToastDismissal(page);
|
||||
}
|
||||
|
||||
async function navigateWithDeveloperToastDismissal(page: Page) {
|
||||
await installDeveloperToastDismissal(page);
|
||||
await page.goto("data:text/html,<button>Continue</button>");
|
||||
await showTransientDeveloperToast(page, true);
|
||||
}
|
||||
|
||||
async function inspectLayoutWhileToastDisappears(page: Page) {
|
||||
const action = page.getByRole("button", { name: "Continue", exact: true });
|
||||
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
|
||||
for (const box of boxes) expect(box).not.toBeNull();
|
||||
await expect(page.locator("#error-toast").getByRole("button")).toHaveCount(0);
|
||||
await action.click();
|
||||
}
|
||||
|
||||
test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => {
|
||||
test.setTimeout(10000);
|
||||
await openTransientDeveloperToast(page);
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
});
|
||||
|
||||
test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(10000);
|
||||
await navigateWithDeveloperToastDismissal(page);
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
await page.evaluate(() => {
|
||||
const root = document.getElementById("error-toast")!.shadowRoot!;
|
||||
const replacement = document.createElement("button");
|
||||
replacement.style.cssText = "width:32px;height:32px";
|
||||
replacement.addEventListener("click", () => replacement.remove());
|
||||
root.append(replacement);
|
||||
});
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { openSettingsSection, clickSettingsBackToWorkspace } from "../support/helpers/settings";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
@@ -136,10 +140,9 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
|
||||
const entries = await menu.getByRole("menuitem").allTextContents();
|
||||
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
|
||||
@@ -191,13 +194,11 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
}
|
||||
});
|
||||
|
||||
async function dragAgentIntoExplorer(
|
||||
async function dragTabIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
agentId: string,
|
||||
tabId: string,
|
||||
) {
|
||||
const source = page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`);
|
||||
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
|
||||
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
|
||||
const sourceBox = await source.boundingBox();
|
||||
const destinationBox = await destination.boundingBox();
|
||||
@@ -213,7 +214,7 @@ async function dragAgentIntoExplorer(
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("agents can still be dragged into Explorer when absent from its + menu", async ({
|
||||
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
|
||||
@@ -230,14 +231,13 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
|
||||
});
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await dragAgentIntoExplorer(page, agent.id);
|
||||
await dragTabIntoExplorer(page, `agent_${agent.id}`);
|
||||
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await testInfo.attach("agent-in-explorer", {
|
||||
body: await page.screenshot(),
|
||||
@@ -247,3 +247,273 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("tablet prototype keeps Explorer tabs when resizing down to a phone", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tablet-explorer-" });
|
||||
try {
|
||||
await page.setViewportSize({ width: 951, height: 669 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureExplorerSidebar(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
|
||||
await openExplorerAgentDraft(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await revealPhoneExplorer(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("phone-shared-explorer.png") });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openExplorerAgentDraft(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Agent/ }).click();
|
||||
await expect(explorerSidebar(page).getByRole("textbox").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function revealPhoneExplorer(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
|
||||
await expect(explorerSidebar(page)).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Close Explorer sidebar", exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
async function cycleWorkspaceTheme(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await page.keyboard.press("Control+Alt+t");
|
||||
}
|
||||
|
||||
test("retains the Explorer host through appearance changes and folding", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-lifetime-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
if (!host) throw new Error("Explorer must be mounted before changing appearance");
|
||||
const previousColor = await explorer.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await cycleWorkspaceTheme(page);
|
||||
await expect
|
||||
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(previousColor);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await expect(explorer).toBeVisible();
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function reorderCompactExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
const explorer = explorerSidebar(page);
|
||||
const files = explorer.getByTestId("workspace-tab-files");
|
||||
const changes = explorer.getByTestId("workspace-tab-changes_tree");
|
||||
const source = await changes.boundingBox();
|
||||
const target = await files.boundingBox();
|
||||
if (!source || !target) throw new Error("Explorer tabs must be visible to reorder");
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(source.x + source.width / 2 - 12, source.y + source.height / 2);
|
||||
await page.mouse.move(target.x + 3, target.y + target.height / 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const afterFiles = await files.boundingBox();
|
||||
const afterChanges = await changes.boundingBox();
|
||||
return Boolean(afterFiles && afterChanges && afterChanges.x < afterFiles.x);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("reorders compact Explorer tabs with a pointer without changing selection", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openFilesPanel(page);
|
||||
await reorderCompactExplorerTabs(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise appearance at its consumers while the real workspace navigator retains the panels.
|
||||
test("updates populated code and diff appearance without losing the editor instance or draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-populated-appearance-" });
|
||||
const source = Array.from(
|
||||
{ length: 120 },
|
||||
(_, index) => `export const line${index} = "retained ${index}";`,
|
||||
).join("\n");
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/appearance.ts`, source);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ codeFontSize: 13, theme: "light" }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"@paseo:changes-preferences",
|
||||
JSON.stringify({ inlineDiff: true, layout: "unified" }),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFilesPanel(page);
|
||||
await openFileFromExplorer(page, "appearance.ts");
|
||||
const editor = page.getByTestId("file-source-editor").filter({ visible: true });
|
||||
const text = editor.locator(".cm-content");
|
||||
await expect(text).toBeVisible();
|
||||
await text.fill(`${source}\n// unsaved local edit`);
|
||||
const instance = await editor.elementHandle();
|
||||
const scroller = editor.locator(".cm-scroller");
|
||||
await scroller.evaluate((node) => {
|
||||
node.scrollTop = 400;
|
||||
});
|
||||
const scrollBefore = await scroller.evaluate((node) => node.scrollTop);
|
||||
await explorerSidebar(page).getByTestId("workspace-tab-changes_tree").click();
|
||||
const diff = explorerSidebar(page).getByTestId("git-diff-canvas");
|
||||
await expect(diff).toBeVisible();
|
||||
await page.keyboard.press("Control+,");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel("Code font size", { exact: true }).fill("19");
|
||||
await page.getByLabel("Code font size", { exact: true }).press("Tab");
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(text).toHaveCSS("font-size", "19px");
|
||||
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("unsaved local edit");
|
||||
expect(await scroller.evaluate((node) => node.scrollTop)).toBe(scrollBefore);
|
||||
await expect(diff).toHaveCSS("font-size", "19px");
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("unsaved local edit");
|
||||
await dragTabIntoExplorer(page, "file_appearance.ts");
|
||||
await expect(explorerSidebar(page).getByTestId("file-source-editor")).toBeVisible();
|
||||
const explorerEditor = await explorerSidebar(page)
|
||||
.getByTestId("file-source-editor")
|
||||
.elementHandle();
|
||||
await text.fill(`${source}\n// retained Explorer edit`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("retained Explorer edit");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retains populated Explorer chat and terminal attachments through appearance, hide and fold", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "explorer-chat-appearance-",
|
||||
title: "Retained chat",
|
||||
initialPrompt: "Show the retention marker",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "Explorer populated paragraph.\n\n```ts\nconst retained = 42;\n```",
|
||||
},
|
||||
});
|
||||
const attaches: string[] = [];
|
||||
page.on("websocket", (socket) =>
|
||||
socket.on("framesent", (frame) => {
|
||||
if (typeof frame.payload !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(frame.payload).message;
|
||||
if (message?.type === "subscribe_terminal_request") attaches.push(message.terminalId);
|
||||
} catch {
|
||||
/* Binary terminal traffic is not a JSON request. */
|
||||
}
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await ensureExplorerSidebar(page);
|
||||
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
|
||||
const explorer = explorerSidebar(page);
|
||||
const paragraph = explorer.getByText("Explorer populated paragraph.", { exact: true });
|
||||
await expect(paragraph).toBeVisible();
|
||||
const composer = explorer.getByRole("textbox", { name: "Message agent..." });
|
||||
await composer.fill("Unsubmitted Explorer draft");
|
||||
const composerNode = await composer.elementHandle();
|
||||
await page.keyboard.press("Control+,");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel("Content font size", { exact: true }).fill("20");
|
||||
await page.getByLabel("Content font size", { exact: true }).press("Tab");
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(paragraph).toHaveCSS("font-size", "20px");
|
||||
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
|
||||
const previousColor = await paragraph.evaluate((node) => getComputedStyle(node).color);
|
||||
await cycleWorkspaceTheme(page);
|
||||
await expect
|
||||
.poll(() => paragraph.evaluate((node) => getComputedStyle(node).color))
|
||||
.not.toBe(previousColor);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: /^Terminal/ })
|
||||
.first()
|
||||
.click();
|
||||
const terminal = explorer.getByTestId("terminal-surface");
|
||||
await expect(terminal).toBeVisible();
|
||||
await expect.poll(() => attaches.length).toBe(1);
|
||||
const terminalNode = await terminal.elementHandle();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
expect(attaches).toHaveLength(1);
|
||||
await explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`).click();
|
||||
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
|
||||
await expect(paragraph).toHaveCSS("font-size", "20px");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact file sheets cover Explorer and return to its retained file list", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-sheet-layer-" });
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
await openFileFromExplorer(page, "sheet-marker.txt");
|
||||
await expect(page.getByText("Explorer sheet marker", { exact: true })).toBeVisible();
|
||||
// A real click also proves the sheet draws above the retained Explorer overlay.
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByText("Explorer sheet marker", { exact: true })).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
expect(await host!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,9 +247,7 @@ test.describe("New workspace: fullscreen composer across the compact breakpoint"
|
||||
await expectNewWorkspaceDraft(page, `${draft} wide`);
|
||||
});
|
||||
|
||||
// Terminal mode and its prompt are screen state, and the shell remounts the screen
|
||||
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
|
||||
// layout only.
|
||||
// Exercise the terminal prompt's fullscreen and compact launch sheet on the phone.
|
||||
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
// The compact launch control opens a sheet, not the desktop menu.
|
||||
|
||||
@@ -5,10 +5,6 @@ import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
@@ -91,12 +87,6 @@ async function useCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
|
||||
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openPluginModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
||||
}
|
||||
@@ -158,7 +148,6 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
|
||||
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
||||
await useCompactLayout(page);
|
||||
await reopenHostUiPluginFromCompactSidebar(page);
|
||||
await openPluginModal(page);
|
||||
await expectCompactPluginSheet(page);
|
||||
await savePluginIssue(page);
|
||||
|
||||
@@ -2,10 +2,6 @@ import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
openMobileAgentSidebar,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function openExample(page: Page, example: string) {
|
||||
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
|
||||
@@ -122,9 +118,7 @@ async function openWideExamples(page: Page) {
|
||||
|
||||
async function openCompactExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectDeniedCopyFeedback(page: Page) {
|
||||
|
||||
@@ -15,6 +15,7 @@ async function closeMenuSheet(page: Page): Promise<void> {
|
||||
|
||||
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-filter-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -26,6 +27,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
/^Grouping/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
"Show background",
|
||||
]);
|
||||
await closeMenuSheet(page);
|
||||
@@ -53,6 +55,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
});
|
||||
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await secondProject.cleanup();
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -624,7 +624,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
|
||||
test("yields app navigation to Explorer and restores it when Explorer closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
||||
|
||||
try {
|
||||
@@ -636,7 +638,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { openSessions, clickSessionRow } from "../support/helpers/archive-tab";
|
||||
import {
|
||||
seedFleetSubagents,
|
||||
delegateFleetThroughMcp,
|
||||
observeFleetRouteOwnership,
|
||||
readFleetCompletion,
|
||||
connectFleetDirectHost,
|
||||
openFleetParentTrack,
|
||||
readFleetAuthorityState,
|
||||
expectFleetRouteUnavailable,
|
||||
} from "../support/helpers/subagents";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
@@ -6,6 +19,8 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
test.use({ e2eInjectPaseoTools: true });
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||
@@ -147,3 +162,167 @@ test("a preferred chat file link still opens beside its right-pane agent", async
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a fleet child retains its origin beside direct access through collision, catalog push, reload and revocation", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const fleet = await seedFleetSubagents();
|
||||
try {
|
||||
const a = fleet.parent.serverId;
|
||||
const b = fleet.destination.serverId;
|
||||
await connectFleetDirectHost(page, fleet);
|
||||
await openFleetParentTrack(page);
|
||||
const rows = page.getByTestId(`subagents-track-row-${fleet.remote.id}`);
|
||||
await expect(rows).toHaveCount(2);
|
||||
const remote = rows.filter({ hasText: "Fleet child on B" });
|
||||
await expect(remote).toBeVisible();
|
||||
await expect(remote.getByTestId(`subagents-track-row-facts-${fleet.remote.id}`)).toHaveText(
|
||||
"Ten second stream · Low · Load test",
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
|
||||
.toBe("online");
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
|
||||
.toBeGreaterThan(0);
|
||||
const beforePush = await readFleetAuthorityState(page, a, b);
|
||||
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
|
||||
.toBeGreaterThan(beforePush.routed.catalogVersion);
|
||||
await remote.click();
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.pathname.includes(`/h/${b}/workspace/`) && url.searchParams.get("origin") === a,
|
||||
);
|
||||
await submitMessage(page, "ROUTED_FLEET_PROMPT");
|
||||
await expect
|
||||
.poll(async () => (await fleet.remote.refresh())?.agent.lastUserMessageAt)
|
||||
.not.toBeNull();
|
||||
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||
const routedUrl = page.url();
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await fleet.remote.waitForFinish();
|
||||
// Reopen direct B through ordinary history, then retain it beside the routed conversation.
|
||||
await openSessions(page);
|
||||
await clickSessionRow(page, "Fleet child on B");
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.pathname.includes(`/h/${b}/workspace/`) && !url.searchParams.has("origin"),
|
||||
);
|
||||
await openFleetParentTrack(page);
|
||||
await page
|
||||
.getByTestId(`subagents-track-row-${fleet.remote.id}`)
|
||||
.filter({ hasText: "Fleet child on B" })
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
|
||||
.toBe("online");
|
||||
await fleet.disable();
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
|
||||
.not.toBe("online");
|
||||
const withdrawn = await readFleetAuthorityState(page, a, b);
|
||||
expect(withdrawn.direct.status).toBe("online");
|
||||
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.catalogVersion)
|
||||
.toBeGreaterThan(withdrawn.direct.catalogVersion);
|
||||
expect((await readFleetAuthorityState(page, a, b)).routed.catalogVersion).toBe(
|
||||
withdrawn.routed.catalogVersion,
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||
// Direct B remains a separate usable authority; its newer evidence cannot refill A's withdrawn scope.
|
||||
await page.goto(buildHostAgentDetailRoute(b, fleet.remote.id, fleet.workspace.workspaceId));
|
||||
await submitMessage(page, "DIRECT_ONLY_AFTER_REVOCATION");
|
||||
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
|
||||
await page.goto(routedUrl);
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
await expectFleetRouteUnavailable(page, a, b);
|
||||
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toHaveCount(0);
|
||||
const beforeRecovery = (await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent
|
||||
.lastUserMessageAt;
|
||||
await fleet.enable();
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
|
||||
.toBe("online");
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
|
||||
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
|
||||
await submitMessage(page, "AFTER_REENABLE_THROUGH_A");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
|
||||
)
|
||||
.not.toBe(beforeRecovery);
|
||||
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
|
||||
} finally {
|
||||
await fleet.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an agent delegates through MCP to self and B, opens both ordinary conversations and receives correlated completion", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
const counts = observeFleetRouteOwnership(page);
|
||||
const fleet = await delegateFleetThroughMcp();
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: fleet.workspace.workspaceId,
|
||||
agentId: fleet.parent.id,
|
||||
});
|
||||
console.log("Fleet ordinary parent ownership", await counts());
|
||||
await openSubagentsTrack(page);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.local.id}`)).toContainText(
|
||||
"MCP child on A",
|
||||
);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
|
||||
"MCP child on B",
|
||||
);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
|
||||
"Fleet B",
|
||||
);
|
||||
await page.getByTestId(`subagents-track-row-${fleet.local.id}`).click();
|
||||
await expect(page).toHaveURL((url) =>
|
||||
url.pathname.includes(`/h/${fleet.local.serverId}/workspace/`),
|
||||
);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await fleet.expectCompletion(fleet.local);
|
||||
await submitMessage(page, "UI_MCP_FLEET_LOCAL");
|
||||
await expect(page.getByText("UI_MCP_FLEET_LOCAL", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
(await fleet.direct.agents.ref(fleet.local.id).refresh())?.agent.lastUserMessageAt,
|
||||
).toBeNull();
|
||||
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${fleet.remote.id}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`) &&
|
||||
url.searchParams.get("origin") === fleet.parent.serverId,
|
||||
);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
console.log("Fleet destination conversation ownership", await counts());
|
||||
await fleet.expectCompletion(fleet.remote);
|
||||
await submitMessage(page, "UI_MCP_FLEET_REMOTE");
|
||||
await expect(page.getByText("UI_MCP_FLEET_REMOTE", { exact: true })).toBeVisible();
|
||||
expect(
|
||||
(await fleet.origin.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
|
||||
).toBeNull();
|
||||
await fleet.expectCompletion(fleet.local);
|
||||
await fleet.expectCompletion(fleet.remote);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: fleet.workspace.workspaceId,
|
||||
agentId: fleet.parent.id,
|
||||
});
|
||||
await readFleetCompletion(page, fleet.local);
|
||||
await readFleetCompletion(page, fleet.remote);
|
||||
} finally {
|
||||
await fleet.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@ import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
@@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
@@ -178,7 +179,7 @@ test.describe("usage modal", () => {
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("31% used")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
@@ -240,15 +241,15 @@ test.describe("usage modal", () => {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromItem(page);
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await expect
|
||||
.poll(() => forcedRefreshes(usage))
|
||||
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
await expect(group.getByText("58% used")).toBeVisible();
|
||||
await expect(group.getByText("12% used")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
@@ -332,7 +333,7 @@ test.describe("usage modal", () => {
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
@@ -347,7 +348,7 @@ test.describe("usage modal", () => {
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
@@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(screen.getByText("69% left")).toBeVisible();
|
||||
await showUsageAs(page, "used");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, 31% · resets /,
|
||||
/^Pin Claude Session, 31% used · resets /,
|
||||
);
|
||||
await openUsageOptions(page);
|
||||
await closeUsageOptions(page);
|
||||
@@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
|
||||
},
|
||||
]);
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
@@ -599,6 +600,7 @@ for (const compact of [false, true]) {
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
@@ -606,7 +608,10 @@ for (const compact of [false, true]) {
|
||||
if (compact) await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, ["Claude: Initial failure"]);
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
await test.step("developer errors do not interrupt freshness inspection", async () => {
|
||||
await page.evaluate(() => console.error("E2E: developer toast while Usage is open"));
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
});
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -615,7 +620,8 @@ for (const compact of [false, true]) {
|
||||
// Move only the browser clock; real daemon fetches continue using the host clock.
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
@@ -640,8 +646,9 @@ for (const compact of [false, true]) {
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43%");
|
||||
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
@@ -651,7 +658,88 @@ for (const compact of [false, true]) {
|
||||
|
||||
async function expectRetainedLoginUsage(page: Page, message: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
||||
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
||||
}
|
||||
|
||||
async function switchToJapanese(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "general");
|
||||
await page.getByRole("button", { name: "System", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
|
||||
await page.getByRole("button", { name: "戻る", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openJapaneseUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "使用状況", exact: true }).click();
|
||||
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
|
||||
await switchToJapanese(page);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
|
||||
await openJapaneseUsage(page);
|
||||
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "設定", exact: true }).last().click();
|
||||
await expect(page.getByText("割合", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("残り", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const now = new Date();
|
||||
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
|
||||
],
|
||||
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
|
||||
});
|
||||
try {
|
||||
await openCompactUsageWithClock(page, now);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||
await advanceUsageClock(page, 120_000);
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||
/^resets (28|29)m$/,
|
||||
);
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openCompactUsageWithClock(page: Page, now: Date) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.clock.install({ time: now });
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
}
|
||||
|
||||
async function advanceUsageClock(page: Page, milliseconds: number) {
|
||||
await page.clock.fastForward(milliseconds);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("70% used")).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
|
||||
await expect(card.getByText("64%")).toBeVisible();
|
||||
await expect(card.getByText("64% used")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
|
||||
@@ -4,8 +4,8 @@ env APP_ID=sh.paseo.debug
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-explorer-toggle\""
|
||||
wait "id=\"explorer-tab-files\"" 10000
|
||||
press "id=\"explorer-tab-files\""
|
||||
wait "id=\"workspace-tab-files\"" 10000
|
||||
press "id=\"workspace-tab-files\""
|
||||
wait "id=\"file-explorer-row-0\"" 10000
|
||||
longpress "id=\"file-explorer-row-0\"" 600
|
||||
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Native retained status-ring regression
|
||||
|
||||
This runs the production native ring and clock inside real retained panels with
|
||||
Reanimated on Android. A probe consumes the same public rotation hook and counts
|
||||
UI-runtime value changes. No Reanimated mocks or provider processes are used.
|
||||
|
||||
Use an Android development client compatible with the checkout's native dependencies.
|
||||
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
|
||||
|
||||
```tsx
|
||||
import "./src/polyfills/install-early";
|
||||
import "./src/styles/unistyles";
|
||||
import { registerRootComponent } from "expo";
|
||||
import NativeRingRegression from "./e2e/mobile/status-ring/native";
|
||||
registerRootComponent(NativeRingRegression);
|
||||
```
|
||||
|
||||
Start Metro in `packages/app`:
|
||||
|
||||
```sh
|
||||
APP_VARIANT=development npx expo start --localhost --port 8096
|
||||
```
|
||||
|
||||
With one Android emulator running, open that bundle:
|
||||
|
||||
```sh
|
||||
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
|
||||
```
|
||||
|
||||
The test checks visible rotation, hidden-panel inactivity while another ring is
|
||||
visible, inactivity with both panels hidden, and synchronized resumption. Each
|
||||
sample reads counters on the UI runtime; elapsed time includes device-command
|
||||
latency. The baseline fails at the first hidden-panel assertion.
|
||||
|
||||
Restore the original `packages/app/index.ts`, close the agent-device session,
|
||||
and stop Metro afterward. The fixture substitutes running rings for a live agent
|
||||
and toggles retained panels directly; it does not exercise workspace navigation
|
||||
or measure whole-app CPU usage. It is excluded from routine unit tests because
|
||||
it requires a native runtime.
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, metroUrl] = process.argv;
|
||||
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
function evaluate(expression) {
|
||||
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
|
||||
return value === "undefined" ? undefined : JSON.parse(value);
|
||||
}
|
||||
let target;
|
||||
const deadline = Date.now() + 30_000;
|
||||
do {
|
||||
target = ad("cdp", "target", "list", "--url", metroUrl)
|
||||
.split("\n")
|
||||
.find((line) => line.includes("sh.paseo.debug"));
|
||||
if (!target) await delay(250);
|
||||
} while (!target && Date.now() < deadline);
|
||||
assert(target, "Open the Android dev client on this Metro bundle first");
|
||||
ad("cdp", "target", "select", target.split(/\s+/)[0]);
|
||||
let ready = false;
|
||||
do {
|
||||
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
|
||||
if (!ready) await delay(250);
|
||||
} while (!ready && Date.now() < deadline);
|
||||
assert(ready, "The two native probes must mount before sampling");
|
||||
async function sample() {
|
||||
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
|
||||
await delay(100);
|
||||
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
|
||||
}
|
||||
async function observe(label) {
|
||||
const before = await sample();
|
||||
await delay(600);
|
||||
const after = await sample();
|
||||
const deltas = Object.fromEntries(
|
||||
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
|
||||
);
|
||||
console.log(JSON.stringify({ label, deltas, after }));
|
||||
return { deltas, after };
|
||||
}
|
||||
const visible = await observe("both visible");
|
||||
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
|
||||
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
|
||||
ad("press", 'label="HIDE FIRST"');
|
||||
await delay(200);
|
||||
const hidden = await observe("first hidden, second visible");
|
||||
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
|
||||
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
|
||||
ad("press", 'label="HIDE SECOND"');
|
||||
await delay(200);
|
||||
const allHidden = await observe("both hidden");
|
||||
assert.equal(allHidden.deltas.first, 0);
|
||||
assert.equal(allHidden.deltas.second, 0);
|
||||
ad("press", 'label="SHOW FIRST"');
|
||||
ad("press", 'label="SHOW SECOND"');
|
||||
await delay(200);
|
||||
const restored = await observe("both shown again");
|
||||
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
|
||||
assert.equal(
|
||||
restored.after.first.rotation,
|
||||
restored.after.second.rotation,
|
||||
"Restored rings must share the current phase",
|
||||
);
|
||||
console.log("PASS native retained-ring lifecycle");
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
import { Button, Text, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { useStatusRingRotation } from "@/components/status-ring/clock";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
|
||||
interface Sample {
|
||||
updates: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __ringSamples: Record<string, Sample>;
|
||||
var __ringReaders: Record<string, () => void>;
|
||||
}
|
||||
|
||||
function recordSample(name: string, value: { updates: number; rotation: number }) {
|
||||
globalThis.__ringSamples[name] = value;
|
||||
}
|
||||
|
||||
function Probe({ name }: { name: string }) {
|
||||
const rotation = useStatusRingRotation();
|
||||
const updates = useSharedValue(0);
|
||||
useAnimatedReaction(
|
||||
() => rotation.value,
|
||||
() => {
|
||||
updates.value += 1;
|
||||
},
|
||||
);
|
||||
// Read the real UI runtime, without scheduling a continuous JS observer.
|
||||
useLayoutEffect(() => {
|
||||
globalThis.__ringSamples ??= {};
|
||||
globalThis.__ringReaders ??= {};
|
||||
globalThis.__ringReaders[name] = () =>
|
||||
scheduleOnUI(() => {
|
||||
"worklet";
|
||||
const sample = { updates: updates.value, rotation: rotation.value };
|
||||
scheduleOnRN(recordSample, name, sample);
|
||||
});
|
||||
return () => {
|
||||
delete globalThis.__ringReaders[name];
|
||||
};
|
||||
}, [name, rotation, updates]);
|
||||
return <StatusRing />;
|
||||
}
|
||||
|
||||
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
|
||||
|
||||
export default function NativeRingRegression() {
|
||||
const [first, setFirst] = useState(true);
|
||||
const [second, setSecond] = useState(true);
|
||||
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
|
||||
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text>Native retained status rings</Text>
|
||||
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
|
||||
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
|
||||
<RetainedPanel active={first}>
|
||||
<Probe name="first" />
|
||||
</RetainedPanel>
|
||||
<RetainedPanel active={second}>
|
||||
<Probe name="second" />
|
||||
</RetainedPanel>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** @param {import('@playwright/test').Page} page */
|
||||
export async function installDeveloperToastDismissal(page) {
|
||||
// Keep developer chrome out of app actions without pointer/focus changes.
|
||||
// Locator handlers can deadlock when concurrent checks dismiss the same toast.
|
||||
const install = () => {
|
||||
let observedRoot;
|
||||
const options = { childList: true, subtree: true };
|
||||
const dismiss = () => {
|
||||
const toast = document.getElementById("error-toast");
|
||||
const root = toast?.shadowRoot ?? toast;
|
||||
if (root && root !== observedRoot) {
|
||||
observer.disconnect();
|
||||
observer.observe(document, options);
|
||||
observer.observe(root, options);
|
||||
observedRoot = root;
|
||||
}
|
||||
// Expo's toast root contains dismiss buttons; its overlay has a different ID.
|
||||
for (const button of root?.querySelectorAll("button") ?? [])
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
};
|
||||
const observer = new MutationObserver(dismiss);
|
||||
observer.observe(document, options);
|
||||
dismiss();
|
||||
};
|
||||
await page.addInitScript(install);
|
||||
await page.evaluate(install);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { installDeveloperToastDismissal } from "./developer-ui.mjs";
|
||||
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
||||
import { startE2EWorker } from "./helpers/e2e-worker";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -130,6 +131,8 @@ const test = daemonTest.extend<{
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
await installDeveloperToastDismissal(page);
|
||||
|
||||
const entries: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
|
||||
@@ -134,6 +134,8 @@ function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>):
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: "none",
|
||||
// Automation never opens native DevTools or installs its desktop shell.
|
||||
EXPO_UNSTABLE_HEADLESS: "1",
|
||||
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inspectSessionWireRequest } from "./session-wire";
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
@@ -16,11 +17,7 @@ interface SendAgentMessageRequest {
|
||||
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: Record<string, unknown>;
|
||||
};
|
||||
const request = envelope.type === "session" ? envelope.message : null;
|
||||
const request = inspectSessionWireRequest(message)?.request;
|
||||
if (
|
||||
request?.type !== "send_agent_message_request" ||
|
||||
typeof request.requestId !== "string" ||
|
||||
|
||||
@@ -363,10 +363,11 @@ export async function resizeComposerViewport(
|
||||
await page.setViewportSize(sizes[orientation]);
|
||||
// Before the app has loaded there is no layout to wait for.
|
||||
if (page.url() === "about:blank") return;
|
||||
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
|
||||
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
|
||||
// wait for that layout before returning.
|
||||
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
|
||||
// The shell and editor survive the breakpoint. Wait for the shell's layout
|
||||
// marker to update before interacting with the resized composer.
|
||||
await expect(
|
||||
page.getByTestId(orientation === "landscape" ? "wide-shell" : "compact-shell"),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function selectComposerDraftRange(
|
||||
page: Page,
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
|
||||
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toBeInViewport();
|
||||
await expect(card).toBeInViewport({ ratio: 1 });
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,3 +74,12 @@ export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/pr
|
||||
).href;
|
||||
return import(moduleUrl);
|
||||
}
|
||||
|
||||
export async function loadPaseoApiFactory(): Promise<
|
||||
(typeof import("@getpaseo/client"))["createPaseoApi"]
|
||||
> {
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.resolve(__dirname, "../../../../../packages/client/dist/index.js"),
|
||||
).href;
|
||||
return (await import(moduleUrl)).createPaseoApi;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inspectSessionWireRequest } from "./session-wire";
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
@@ -71,12 +72,11 @@ function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
||||
|
||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
const session = inspectSessionWireRequest(message);
|
||||
if (session) return session.request;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: ClientRequest;
|
||||
};
|
||||
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||
const envelope = JSON.parse(message) as ClientRequest;
|
||||
return envelope.type === "session" ? null : envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Inspect an ordinary session request while preserving any owning transport envelopes. */
|
||||
export function inspectSessionWireRequest(message: string | Buffer): {
|
||||
request: Record<string, unknown>;
|
||||
reply(response: unknown): string;
|
||||
} | null {
|
||||
const routes: string[] = [];
|
||||
let raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
for (;;) {
|
||||
const envelope = JSON.parse(raw);
|
||||
if (envelope?.type !== "session" || !envelope.message || typeof envelope.message !== "object")
|
||||
return null;
|
||||
const request: Record<string, unknown> = envelope.message;
|
||||
if (request.type !== "hosts.route.frame.request") {
|
||||
return {
|
||||
request,
|
||||
reply(response) {
|
||||
let data = JSON.stringify({ type: "session", message: response });
|
||||
for (const subscriptionId of routes.toReversed()) {
|
||||
data = JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "hosts.route.frame",
|
||||
payload: { subscriptionId, data, binary: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof request.routeId !== "string" ||
|
||||
typeof request.data !== "string" ||
|
||||
request.binary !== false
|
||||
)
|
||||
return null;
|
||||
routes.push(request.routeId);
|
||||
raw = request.data;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -276,15 +276,22 @@ export async function swipeOpenMobileExplorer(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function closeMobileExplorer(page: Page): Promise<void> {
|
||||
const closeButton = page.getByTestId("explorer-close");
|
||||
const closeButton = page
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByRole("button", { name: "Close Explorer sidebar", exact: true });
|
||||
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeInViewport({
|
||||
ratio: 1,
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { addConnectedHostAndReload } from "./hosts";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { openSessions, clickSessionRow } from "./archive-tab";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient, loadPaseoApiFactory } from "./daemon-client-loader";
|
||||
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { inspectSessionWireRequest } from "./session-wire";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
@@ -251,22 +263,19 @@ export async function rejectNextManagedSubagentArchiveRequest(
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const requestId = archiveRequestId(message, subagentId);
|
||||
if (requestId && !rejected) {
|
||||
const intercepted = archiveRequest(message, subagentId);
|
||||
if (intercepted && !rejected) {
|
||||
rejected = true;
|
||||
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
|
||||
// correlated error travels back through the real browser client and mutation rollback.
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "archive_agent_request",
|
||||
error: "Archive rejected for test",
|
||||
code: "archive_rejected",
|
||||
},
|
||||
intercepted.reply({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: intercepted.request.requestId,
|
||||
requestType: "archive_agent_request",
|
||||
error: "Archive rejected for test",
|
||||
code: "archive_rejected",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -282,28 +291,18 @@ export async function rejectNextManagedSubagentArchiveRequest(
|
||||
}
|
||||
|
||||
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
|
||||
return archiveRequestId(message, subagentId) !== null;
|
||||
return archiveRequest(message, subagentId) !== null;
|
||||
}
|
||||
|
||||
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "archive_agent_request" &&
|
||||
envelope.message.agentId === subagentId &&
|
||||
typeof envelope.message.requestId === "string"
|
||||
) {
|
||||
return envelope.message.requestId;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function archiveRequest(message: WebSocketMessage, subagentId: string) {
|
||||
const inspected = inspectSessionWireRequest(message);
|
||||
if (
|
||||
inspected?.request.type === "archive_agent_request" &&
|
||||
inspected.request.agentId === subagentId &&
|
||||
typeof inspected.request.requestId === "string"
|
||||
)
|
||||
return inspected;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||
@@ -320,3 +319,354 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
|
||||
await expect(detachButton).toBeVisible({ timeout: 30_000 });
|
||||
await detachButton.click();
|
||||
}
|
||||
|
||||
/** Real equal-ID children in equal-ID workspaces, with both route authorities independently live. */
|
||||
export async function seedFleetSubagents() {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "fleet-track-" });
|
||||
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
|
||||
const clients: DaemonClient[] = [];
|
||||
const apis: ReturnType<Awaited<ReturnType<typeof loadPaseoApiFactory>>>[] = [];
|
||||
const close = async () => {
|
||||
await Promise.allSettled(apis.map((api) => api.dispose()));
|
||||
await Promise.allSettled(clients.map((client) => client.close()));
|
||||
await destination.close();
|
||||
await workspace.cleanup();
|
||||
};
|
||||
try {
|
||||
const createApi = await loadPaseoApiFactory();
|
||||
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-origin" });
|
||||
clients.push(aClient);
|
||||
const bClient = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "fleet-destination",
|
||||
port: destination.port,
|
||||
});
|
||||
clients.push(bClient);
|
||||
const origin = createApi(aClient);
|
||||
const direct = createApi(bClient);
|
||||
apis.push(origin, direct);
|
||||
const registered = await origin.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await origin.hosts.list()).hosts.find(
|
||||
(host) => host.registrationId === registered.registrationId,
|
||||
)?.status,
|
||||
)
|
||||
.toBe("online");
|
||||
const destinationWorkspace = await direct.workspaces.create({
|
||||
workspaceId: workspace.workspaceId,
|
||||
source: { kind: "directory", path: workspace.repoPath },
|
||||
});
|
||||
expect(destinationWorkspace.id).toBe(workspace.workspaceId);
|
||||
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
|
||||
const parent = await origin.workspaces
|
||||
.ref(workspace.workspaceId)
|
||||
.agents.create({ title: "Fleet parent", config });
|
||||
const agentId = randomUUID();
|
||||
const local = await parent.subagents.create({ agentId, title: "Fleet child on A", config });
|
||||
const remote = await parent.subagents.create({
|
||||
agentId,
|
||||
serverId: destination.serverId,
|
||||
workspaceId: destinationWorkspace.id,
|
||||
title: "Fleet child on B",
|
||||
config,
|
||||
});
|
||||
return {
|
||||
workspace,
|
||||
destination,
|
||||
origin,
|
||||
direct,
|
||||
parent,
|
||||
local,
|
||||
remote,
|
||||
disable: () => origin.hosts.update(registered.registrationId, { enabled: false }),
|
||||
enable: () => origin.hosts.update(registered.registrationId, { enabled: true }),
|
||||
close,
|
||||
};
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectFleetDirectHost(
|
||||
page: Page,
|
||||
fleet: Awaited<ReturnType<typeof seedFleetSubagents>>,
|
||||
) {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: fleet.workspace.workspaceId,
|
||||
agentId: fleet.parent.id,
|
||||
});
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: fleet.destination.serverId,
|
||||
port: fleet.destination.port,
|
||||
label: "Fleet B",
|
||||
});
|
||||
await page.evaluate(
|
||||
(host) => localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([host])),
|
||||
buildSeededHost({
|
||||
serverId: fleet.destination.serverId,
|
||||
endpoint: `127.0.0.1:${fleet.destination.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
label: "Fleet B",
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
buildHostAgentDetailRoute(
|
||||
fleet.destination.serverId,
|
||||
fleet.remote.id,
|
||||
fleet.workspace.workspaceId,
|
||||
),
|
||||
);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openFleetParentTrack(page: Page) {
|
||||
await openSessions(page);
|
||||
await clickSessionRow(page, "Fleet parent");
|
||||
await openSubagentsTrack(page);
|
||||
}
|
||||
|
||||
export async function readFleetAuthorityState(
|
||||
page: Page,
|
||||
originServerId: string,
|
||||
serverId: string,
|
||||
) {
|
||||
return page.evaluate(
|
||||
({ originServerId: sourceId, serverId: destinationId }) => {
|
||||
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
|
||||
const read = (origin: string) => {
|
||||
const binding = runtime
|
||||
.getAuthorityBindings()
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.reference.originServerId === origin &&
|
||||
candidate.reference.serverId === destinationId,
|
||||
);
|
||||
const queries =
|
||||
binding?.queries
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: ["providersSnapshot", destinationId] }) ?? [];
|
||||
return {
|
||||
status: binding?.runtime.getSnapshot(destinationId)?.connectionStatus ?? "absent",
|
||||
hydrated: binding?.sessionStore.getState().sessions[destinationId]?.hasHydratedWorkspaces,
|
||||
workspaceIds: [
|
||||
...(binding?.sessionStore.getState().sessions[destinationId]?.workspaces.keys() ?? []),
|
||||
],
|
||||
catalogVersion: Math.max(0, ...queries.map((query) => query.state.dataUpdatedAt)),
|
||||
};
|
||||
};
|
||||
return { direct: read(destinationId), routed: read(sourceId) };
|
||||
},
|
||||
{ originServerId, serverId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectFleetRouteUnavailable(
|
||||
page: Page,
|
||||
originServerId: string,
|
||||
serverId: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => (await readFleetAuthorityState(page, originServerId, serverId)).routed.status)
|
||||
.toMatch(/^(offline|error)$/);
|
||||
const unavailable = page.getByText(/^(Cannot reach .+|.+ is offline)$/).filter({ visible: true });
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(unavailable.or(composer)).toBeVisible();
|
||||
if (await composer.isVisible()) {
|
||||
if (await composer.isEditable()) {
|
||||
await composer.fill("MUST_NOT_USE_DIRECT_AUTHORITY");
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
|
||||
} else await expect(composer).toBeDisabled();
|
||||
} else
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Primary children are created by the agent's real MCP endpoint, never the seed SDK. */
|
||||
export async function delegateFleetThroughMcp() {
|
||||
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
||||
const { StreamableHTTPClientTransport } =
|
||||
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
||||
const { getE2EDaemonPort } = await import("./daemon-port");
|
||||
const { z } = await import("zod");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "mcp-fleet-journey-" });
|
||||
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
|
||||
const createApi = await loadPaseoApiFactory();
|
||||
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "mcp-fleet-origin" });
|
||||
const bClient = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "mcp-fleet-observer",
|
||||
port: destination.port,
|
||||
});
|
||||
const origin = createApi(aClient);
|
||||
const direct = createApi(bClient);
|
||||
const mcp = new Client({ name: "fleet-delegating-agent", version: "1.0.0" });
|
||||
const close = async () => {
|
||||
await mcp.close();
|
||||
await Promise.allSettled([origin.dispose(), direct.dispose()]);
|
||||
await Promise.allSettled([aClient.close(), bClient.close()]);
|
||||
await destination.close();
|
||||
await workspace.cleanup();
|
||||
};
|
||||
const call = async (name: string, args: Record<string, unknown> = {}) => {
|
||||
const result = await mcp.callTool({ name, arguments: args });
|
||||
expect(result, `${name} result`).not.toMatchObject({ isError: true });
|
||||
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
||||
};
|
||||
try {
|
||||
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
|
||||
const parent = await origin.workspaces
|
||||
.ref(workspace.workspaceId)
|
||||
.agents.create({ title: "Fleet MCP parent", config });
|
||||
const registered = await origin.hosts.add({
|
||||
connection: `127.0.0.1:${destination.port}`,
|
||||
label: "Fleet B",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await origin.hosts.list()).hosts.find(
|
||||
(host) => host.registrationId === registered.registrationId,
|
||||
)?.status,
|
||||
)
|
||||
.toBe("online");
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
const hosts = await call("list_hosts");
|
||||
expect(hosts).toMatchObject({
|
||||
hosts: expect.arrayContaining([
|
||||
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
|
||||
]),
|
||||
});
|
||||
const target = await call("create_workspace", {
|
||||
serverId: destination.serverId,
|
||||
isolation: "local",
|
||||
path: workspace.repoPath,
|
||||
title: "Fleet B workspace",
|
||||
});
|
||||
const targetWorkspaceId = z.string().parse(target.workspaceId);
|
||||
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
|
||||
workspaces: expect.arrayContaining([
|
||||
expect.objectContaining({ workspaceId: targetWorkspaceId }),
|
||||
]),
|
||||
});
|
||||
const createChild = async (serverId: string, workspaceId: string, title: string) => {
|
||||
const result = await call("create_agent", {
|
||||
serverId,
|
||||
workspaceId,
|
||||
title,
|
||||
provider: config.provider,
|
||||
settings: { modeId: config.modeId },
|
||||
initialPrompt: `Complete ${title}`,
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
const agentId = z.string().parse(result.agentId);
|
||||
const submissionId = z.string().parse(result.submissionId);
|
||||
expect(await call("get_agent_status", { serverId, agentId })).toMatchObject({ serverId });
|
||||
expect(await call("get_agent_activity", { serverId, agentId })).toMatchObject({ serverId });
|
||||
expect(await call("list_agents", { serverId })).toMatchObject({
|
||||
serverId,
|
||||
agents: expect.arrayContaining([expect.objectContaining({ id: agentId })]),
|
||||
});
|
||||
return { id: agentId, serverId, workspaceId, submissionId, title };
|
||||
};
|
||||
const local = await createChild(parent.serverId, workspace.workspaceId, "MCP child on A");
|
||||
const remote = await createChild(destination.serverId, targetWorkspaceId, "MCP child on B");
|
||||
// Opposite-host collisions are unrelated fixture agents. Neither primary child is seeded.
|
||||
await direct.agents.create({
|
||||
agentId: local.id,
|
||||
cwd: workspace.repoPath,
|
||||
title: "Unrelated collision on B",
|
||||
config,
|
||||
});
|
||||
await origin.agents.create({
|
||||
agentId: remote.id,
|
||||
cwd: workspace.repoPath,
|
||||
title: "Unrelated collision on A",
|
||||
config,
|
||||
});
|
||||
expect((await parent.subagents.list()).rows.map((row) => row.child)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ serverId: local.serverId, agentId: local.id },
|
||||
{ serverId: remote.serverId, agentId: remote.id },
|
||||
]),
|
||||
);
|
||||
return {
|
||||
workspace,
|
||||
destination,
|
||||
parent,
|
||||
local,
|
||||
remote,
|
||||
origin,
|
||||
direct,
|
||||
close,
|
||||
async expectCompletion(child: typeof local) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const timeline = await parent.timeline.refetch();
|
||||
return timeline.entries
|
||||
.flatMap(({ item }) =>
|
||||
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
|
||||
)
|
||||
.filter(
|
||||
(message) =>
|
||||
message.event === "finished" &&
|
||||
message.sender.address?.serverId === child.serverId &&
|
||||
message.text.includes(child.submissionId),
|
||||
);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFleetCompletion(
|
||||
page: Page,
|
||||
child: { title: string; submissionId: string },
|
||||
) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: new RegExp(`Agent finished.*${child.title}`) }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Records observed requests and runtime-owned sessions without opening extra scopes. */
|
||||
export function observeFleetRouteOwnership(page: Page) {
|
||||
const requests: Record<string, number> = {};
|
||||
let sockets = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
sockets++;
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = inspectSessionWireRequest(payload)?.request;
|
||||
if (!request || typeof request.type !== "string") return;
|
||||
if (request.subscribe !== undefined || request.type === "hosts.route.open.request")
|
||||
requests[request.type] = (requests[request.type] ?? 0) + 1;
|
||||
});
|
||||
});
|
||||
return async () => ({
|
||||
sockets,
|
||||
cumulativeRequests: { ...requests },
|
||||
bindings: await page.evaluate(() => {
|
||||
const registry = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
|
||||
return registry.getAuthorityBindings().map((binding) => {
|
||||
const id = binding.reference.serverId;
|
||||
const session = binding.sessionStore.getState().sessions[id];
|
||||
return {
|
||||
reference: binding.reference,
|
||||
status: binding.runtime.getSnapshot(id)?.connectionStatus,
|
||||
session: session?.client !== null && session?.client !== undefined,
|
||||
workspaceReplica: session?.hasHydratedWorkspaces === true,
|
||||
timelineReplica: session?.viewedTimelineSync !== undefined,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ useDefaultAndroidSdkVersions()
|
||||
def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
namespace "sh.paseo.diffprototype"
|
||||
defaultConfig {
|
||||
externalNativeBuild {
|
||||
|
||||
+39
-38
@@ -58,11 +58,11 @@
|
||||
"@marijn/find-cluster-break": "^1.0.2",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-native-masked-view/masked-view": "0.3.2",
|
||||
"@react-native/normalize-colors": "0.86.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@shopify/react-native-skia": "2.2.12",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
@@ -75,30 +75,30 @@
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^54.0.18",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-audio": "~1.0.13",
|
||||
"expo-build-properties": "^1.0.9",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "^6.0.15",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
"expo-audio": "~57.0.5",
|
||||
"expo-build-properties": "~57.0.22",
|
||||
"expo-camera": "~57.0.6",
|
||||
"expo-clipboard": "~57.0.2",
|
||||
"expo-constants": "~57.0.22",
|
||||
"expo-crypto": "~57.0.4",
|
||||
"expo-dev-client": "~57.0.20",
|
||||
"expo-document-picker": "~57.0.3",
|
||||
"expo-file-system": "~57.0.7",
|
||||
"expo-haptics": "~57.0.3",
|
||||
"expo-image": "~57.0.5",
|
||||
"expo-image-manipulator": "~57.0.21",
|
||||
"expo-image-picker": "~57.0.20",
|
||||
"expo-keep-awake": "~57.0.2",
|
||||
"expo-linking": "~57.0.13",
|
||||
"expo-localization": "~57.0.2",
|
||||
"expo-notifications": "~57.0.23",
|
||||
"expo-router": "~57.0.28",
|
||||
"expo-sharing": "~57.0.23",
|
||||
"expo-splash-screen": "~57.0.10",
|
||||
"expo-sqlite": "~57.0.4",
|
||||
"expo-system-ui": "~57.0.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
@@ -111,25 +111,25 @@
|
||||
"micromark-util-decode-string": "2.0.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-freeze": "1.0.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "~0.8.3",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.10.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@@ -151,11 +151,12 @@
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"babel-preset-expo": "~57.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
"esbuild": "0.28.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"eslint-config-expo": "~57.0.2",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^20.0.3",
|
||||
|
||||
@@ -4,26 +4,33 @@ const {
|
||||
IOSConfig,
|
||||
withAppDelegate,
|
||||
withDangerousMod,
|
||||
withInfoPlist,
|
||||
withPodfile,
|
||||
} = require("expo/config-plugins");
|
||||
|
||||
const MODULE_SETUP = " PasteInputModule.setup(factory.rootViewFactory)";
|
||||
const REACT_NATIVE_START_END = " launchOptions: launchOptions)\n#endif";
|
||||
const BRIDGING_HEADER_IMPORT = "#import <react-native-paste-input/PasteInputModule.h>";
|
||||
const PODFILE_NEW_ARCH_DISABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const PODFILE_NEW_ARCH_ENABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'";
|
||||
const SCENE_DELEGATE = `
|
||||
@objc(PaseoSceneDelegate)
|
||||
class PaseoSceneDelegate: ExpoAppSceneDelegate {
|
||||
override func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
guard let provider = UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider,
|
||||
let factory = provider.reactNativeFactory else { return }
|
||||
PasteInputModule.setup(factory.rootViewFactory)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function configurePasteInputAppDelegate(contents) {
|
||||
const configured = contents.replace(`\n${MODULE_SETUP}`, "");
|
||||
if (!configured.includes(REACT_NATIVE_START_END)) {
|
||||
throw new Error("Could not register paste input after React Native starts");
|
||||
if (contents.includes("class PaseoSceneDelegate:")) return contents;
|
||||
if (!contents.includes("ExpoReactNativeFactoryProvider")) {
|
||||
throw new Error("Paste input requires Expo scene support");
|
||||
}
|
||||
return configured.replace(
|
||||
REACT_NATIVE_START_END,
|
||||
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
|
||||
);
|
||||
return `${contents.trimEnd()}\n${SCENE_DELEGATE}`;
|
||||
}
|
||||
|
||||
function configurePasteInputBridgingHeader(contents) {
|
||||
@@ -33,19 +40,6 @@ function configurePasteInputBridgingHeader(contents) {
|
||||
return `${contents.trimEnd()}\n${BRIDGING_HEADER_IMPORT}\n`;
|
||||
}
|
||||
|
||||
function configurePasteInputPodfile(contents) {
|
||||
if (contents.includes(PODFILE_NEW_ARCH_ENABLED)) {
|
||||
return contents;
|
||||
}
|
||||
if (!contents.includes(PODFILE_NEW_ARCH_DISABLED)) {
|
||||
throw new Error("Could not configure the new architecture for paste input");
|
||||
}
|
||||
return contents.replace(
|
||||
PODFILE_NEW_ARCH_DISABLED,
|
||||
`${PODFILE_NEW_ARCH_DISABLED}\n${PODFILE_NEW_ARCH_ENABLED}`,
|
||||
);
|
||||
}
|
||||
|
||||
function withPasteInput(config) {
|
||||
const withDelegate = withAppDelegate(config, (modConfig) => {
|
||||
if (modConfig.modResults.language !== "swift") {
|
||||
@@ -54,7 +48,18 @@ function withPasteInput(config) {
|
||||
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
const withPods = withPodfile(withDelegate, (modConfig) => {
|
||||
const withScene = withInfoPlist(withDelegate, (modConfig) => {
|
||||
const scenes =
|
||||
modConfig.modResults.UIApplicationSceneManifest?.UISceneConfigurations
|
||||
?.UIWindowSceneSessionRoleApplication;
|
||||
if (!scenes || scenes.length !== 1) {
|
||||
throw new Error("Paste input requires Expo's single scene configuration");
|
||||
}
|
||||
scenes[0].UISceneDelegateClassName = "PaseoSceneDelegate";
|
||||
return modConfig;
|
||||
});
|
||||
const withPods = withPodfile(withScene, (modConfig) => {
|
||||
// Paste input checks this legacy flag even though RN 0.86 only supports Fabric.
|
||||
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
@@ -79,4 +84,9 @@ function withPasteInputBridgingHeader(config) {
|
||||
module.exports = withPasteInput;
|
||||
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
|
||||
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
|
||||
|
||||
function configurePasteInputPodfile(contents) {
|
||||
const flag = "ENV['RCT_NEW_ARCH_ENABLED'] = '1'";
|
||||
return contents.includes(flag) ? contents : `${flag}\n${contents}`;
|
||||
}
|
||||
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
|
||||
|
||||
@@ -7,22 +7,14 @@ const {
|
||||
} = require("./with-paste-input");
|
||||
|
||||
describe("withPasteInput", () => {
|
||||
it("registers the module after React Native starts", () => {
|
||||
const source = [
|
||||
"import ReactAppDependencyProvider",
|
||||
"",
|
||||
" bindReactNativeFactory(factory)",
|
||||
" factory.startReactNative(",
|
||||
' withModuleName: "main",',
|
||||
" in: window,",
|
||||
" launchOptions: launchOptions)",
|
||||
"#endif",
|
||||
].join("\n");
|
||||
it("registers paste input after Expo connects the React Native scene", () => {
|
||||
const source = "class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {}";
|
||||
const configured = configurePasteInputAppDelegate(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
|
||||
expect(configured).toContain("class PaseoSceneDelegate: ExpoAppSceneDelegate");
|
||||
expect(configured.indexOf("super.scene(")).toBeLessThan(
|
||||
configured.indexOf("PasteInputModule.setup("),
|
||||
);
|
||||
expect(configured).toContain("provider.reactNativeFactory");
|
||||
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
@@ -33,15 +25,11 @@ describe("withPasteInput", () => {
|
||||
expect(configured).toContain("#import <react-native-paste-input/PasteInputModule.h>");
|
||||
expect(configurePasteInputBridgingHeader(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("sets the new architecture environment before CocoaPods evaluates dependencies", () => {
|
||||
it("enables the legacy new-architecture flag required by the paste input podspec", () => {
|
||||
const source =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
"require File.join(File.dirname(`node --print \"require.resolve('expo/package.json')\"`), 'scripts/autolinking')";
|
||||
const configured = configurePasteInputPodfile(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
|
||||
);
|
||||
expect(configured).toBe("ENV['RCT_NEW_ARCH_ENABLED'] = '1'\n" + source);
|
||||
expect(configurePasteInputPodfile(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// After Expo prebuild, run from packages/app/android:
|
||||
// ./gradlew --init-script ../scripts/verify-android-ndk.gradle verifyAndroidNdk
|
||||
gradle.projectsEvaluated {
|
||||
rootProject.tasks.register('verifyAndroidNdk') {
|
||||
doLast {
|
||||
def expected = rootProject.ext.ndkVersion
|
||||
def nativeProjects = rootProject.subprojects.findAll { subproject ->
|
||||
def android = subproject.extensions.findByName('android')
|
||||
android != null && (android.externalNativeBuild.cmake.path != null ||
|
||||
android.externalNativeBuild.ndkBuild.path != null)
|
||||
}
|
||||
assert !nativeProjects.empty : 'No native Android modules found'
|
||||
println "Project NDK: ${expected}"
|
||||
def mismatches = nativeProjects.findAll { subproject ->
|
||||
def actual = subproject.android.ndkVersion
|
||||
println "${subproject.path} NDK: ${actual}"
|
||||
actual != expected
|
||||
}
|
||||
assert mismatches.empty : "Native Android modules must use project NDK ${expected}: ${mismatches*.path}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
|
||||
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -445,11 +445,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { HostAuthoritySessions } from "@/runtime/host-authority-sessions";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
@@ -41,7 +45,7 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
@@ -90,7 +94,6 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
@@ -311,6 +314,7 @@ function HostSessionManager() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostAuthoritySessions />
|
||||
{hosts.map((daemon) => (
|
||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||
))}
|
||||
@@ -468,17 +472,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const viewportWidth = windowWidth - insets.left - insets.right;
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
void updateSettings({ theme: getNextThemePreference(settings.theme) });
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
|
||||
isCompact: isCompactLayout,
|
||||
});
|
||||
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
|
||||
useCompactWebViewportZoomLock(isCompactLayout);
|
||||
const pathname = usePathname();
|
||||
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
||||
@@ -516,7 +518,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const activeWorkspace = useActiveWorkspaceSelection();
|
||||
const explorerVisible = useIsExplorerSidebarOpen({
|
||||
isCompact: isCompactLayout,
|
||||
workspaceKey: activeWorkspace
|
||||
? `${activeWorkspace.serverId}:${activeWorkspace.workspaceId}`
|
||||
: null,
|
||||
});
|
||||
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
explorerVisible: isWorkspaceRoute && explorerVisible,
|
||||
isSettingsRoute: pathname.includes("/settings"),
|
||||
});
|
||||
const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled;
|
||||
@@ -548,28 +558,25 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
if (isWeb) {
|
||||
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
|
||||
}
|
||||
const workspaceChromeStyle = useMemo(
|
||||
() => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }],
|
||||
[insets.left, insets.right, isCompactLayout],
|
||||
);
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
<View style={workspaceChromeStyle}>
|
||||
<WorkspaceLayoutModePublisher />
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
{usesCompactExplorerHost ? (
|
||||
<CompactExplorerSidebarHost
|
||||
enabled={chromeEnabled}
|
||||
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
|
||||
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
|
||||
<WindowChromeRegion
|
||||
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
|
||||
>
|
||||
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
</CompactExplorerSidebarHost>
|
||||
) : (
|
||||
<WindowChromeRegion corners={appChromeLayout.contentCorners}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
)}
|
||||
</WorkspaceExplorerOpenGesture>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -620,15 +627,27 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = isCompactLayout ? (
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
||||
) : (
|
||||
surface
|
||||
return (
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
{surface}
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContainerLifetime({
|
||||
children,
|
||||
compactGesturesEnabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
compactGesturesEnabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComposerEditingSessionsProvider>
|
||||
<CommandCenterProvider>{content}</CommandCenterProvider>
|
||||
<CommandCenterProvider>
|
||||
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
|
||||
{children}
|
||||
</MobileGestureWrapper>
|
||||
</CommandCenterProvider>
|
||||
</ComposerEditingSessionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -662,10 +681,15 @@ function MobileGestureWrapper({
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
|
||||
<View
|
||||
collapsable={false}
|
||||
style={layoutStyles.surfaceFill}
|
||||
testID={isCompactLayout ? "compact-shell" : "wide-shell"}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -10,6 +10,8 @@ const HOST_STACK_SCREEN_OPTIONS = {
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
const SCREEN_OWNED_APPEARANCE = ["workspace/[workspaceId]/index"] as const;
|
||||
|
||||
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
@@ -17,13 +19,20 @@ export default function HostRouteLayout() {
|
||||
}
|
||||
|
||||
function KnownHostRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
origin?: string | string[];
|
||||
}>();
|
||||
const hosts = useHosts();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "host", serverId: routeServerId },
|
||||
route: {
|
||||
kind: "host",
|
||||
serverId: routeServerId,
|
||||
...(typeof params.origin === "string" ? { originServerId: params.origin } : {}),
|
||||
},
|
||||
startupBlocker: bootstrapState.startupBlocker,
|
||||
hostRegistryStatus,
|
||||
hosts,
|
||||
@@ -34,7 +43,10 @@ function KnownHostRoute() {
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<ThemedStack
|
||||
screenOptions={HOST_STACK_SCREEN_OPTIONS}
|
||||
screenOwnedAppearance={SCREEN_OWNED_APPEARANCE}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
@@ -5,21 +6,34 @@ import { useFetchQuery } from "@/data/query";
|
||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { useHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildSettingsHostRoute,
|
||||
withHostRouteAuthority,
|
||||
} from "@/utils/host-routes";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
const { serverId, origin } = useLocalSearchParams<{ serverId?: string; origin?: string }>();
|
||||
const reference = useMemo(
|
||||
() => ({ originServerId: origin ?? serverId ?? "", serverId: serverId ?? "" }),
|
||||
[origin, serverId],
|
||||
);
|
||||
if (!serverId) return null;
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostAgentReadyRouteContent />
|
||||
<HostAuthorityRoute reference={reference}>
|
||||
<HostAgentReadyRouteContent />
|
||||
</HostAuthorityRoute>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -40,10 +54,8 @@ function HostAgentReadyRouteContent() {
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!serverId || !agentId || agentWorkspaceId) return;
|
||||
void getHostRuntimeStore()
|
||||
.prepareAgentRoute(serverId, agentId)
|
||||
.catch(() => undefined);
|
||||
}, [agentId, agentWorkspaceId, serverId]);
|
||||
void runtime.prepareAgentRoute(serverId, agentId).catch(() => undefined);
|
||||
}, [agentId, agentWorkspaceId, serverId, runtime]);
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
);
|
||||
@@ -106,11 +118,23 @@ function HostAgentReadyRouteContent() {
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: resolution.workspaceId,
|
||||
authority: runtime.authorityFor(serverId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
}, [agentId, resolution, router, serverId]);
|
||||
router.replace(
|
||||
resolution.kind === "invalid"
|
||||
? ("/" as Href)
|
||||
: (withHostRouteAuthority(
|
||||
buildHostRootRoute(serverId),
|
||||
runtime.authorityFor(serverId),
|
||||
) as Href),
|
||||
);
|
||||
}, [agentId, resolution, router, serverId, runtime]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (resolution.kind === "lookupError") {
|
||||
@@ -118,21 +142,28 @@ function HostAgentReadyRouteContent() {
|
||||
return;
|
||||
}
|
||||
if (serverId) {
|
||||
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||
void runtime.runProbeCycleNow(serverId);
|
||||
}
|
||||
}, [lookupQuery, resolution.kind, serverId]);
|
||||
}, [lookupQuery, resolution.kind, serverId, runtime]);
|
||||
const handleManageHost = useCallback(() => {
|
||||
if (serverId) {
|
||||
router.push(buildSettingsHostRoute(serverId));
|
||||
router.push(buildSettingsHostRoute(runtime.authorityFor(serverId).originServerId));
|
||||
}
|
||||
}, [router, serverId]);
|
||||
}, [router, serverId, runtime]);
|
||||
const handleBack = useCallback(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||
}, [router, serverId]);
|
||||
router.replace(
|
||||
serverId
|
||||
? (withHostRouteAuthority(
|
||||
buildHostRootRoute(serverId),
|
||||
runtime.authorityFor(serverId),
|
||||
) as Href)
|
||||
: ("/" as Href),
|
||||
);
|
||||
}, [router, serverId, runtime]);
|
||||
|
||||
if (
|
||||
resolution.kind === "waitingForHost" ||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Redirect } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||
import {
|
||||
workspaceSelectionAuthority,
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/last-workspace-selection";
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
@@ -13,10 +19,31 @@ import {
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const serverId = useHostRouteServerId();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const remembered = useLastWorkspaceSelection();
|
||||
const { origin } = useLocalSearchParams<{ origin?: string }>();
|
||||
const selection = useMemo(
|
||||
() =>
|
||||
remembered?.serverId === serverId
|
||||
? { ...remembered, ...(origin ? { authority: { originServerId: origin } } : {}) }
|
||||
: null,
|
||||
[remembered, serverId, origin],
|
||||
);
|
||||
if (!selection) return <HostIndexContent workspaceSelection={null} />;
|
||||
return (
|
||||
<HostAuthorityRoute reference={workspaceSelectionAuthority(selection)}>
|
||||
<HostIndexContent workspaceSelection={selection} />
|
||||
</HostAuthorityRoute>
|
||||
);
|
||||
}
|
||||
|
||||
function HostIndexContent({
|
||||
workspaceSelection,
|
||||
}: {
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const serverId = useHostRouteServerId();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionWorkspaceId =
|
||||
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
|
||||
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
|
||||
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useNavigation } from "expo-router/react-navigation";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { useGlobalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
@@ -24,16 +26,12 @@ import {
|
||||
resolveWorkspaceDeckEntries,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { parseWorkspaceOpenIntent, type WorkspaceOpenIntent } from "@/utils/host-routes";
|
||||
import {
|
||||
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
||||
} from "@/utils/host-route-browser";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
|
||||
@@ -94,24 +92,34 @@ export default function HostWorkspaceIndexRoute() {
|
||||
}
|
||||
|
||||
function HostWorkspaceRouteContent() {
|
||||
const { open } = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
return (
|
||||
<WorkspaceDeck
|
||||
recoveryRequested={parseWorkspaceOpenIntent(getParamValue(open))?.kind === "agent"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceOpenIntentBoundary({
|
||||
selection,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { prepareWorkspaceTab } = useWorkspaceTabNavigation();
|
||||
const navigation = useNavigation();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const { serverId, workspaceId } = selection;
|
||||
const openValue = active ? getParamValue(globalParams.open) : "";
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
@@ -178,6 +186,7 @@ function HostWorkspaceRouteContent() {
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
prepareWorkspaceTab,
|
||||
serverId,
|
||||
workspaceId,
|
||||
]);
|
||||
@@ -190,7 +199,7 @@ function HostWorkspaceRouteContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
@@ -274,7 +283,20 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDeckEntry({
|
||||
function WorkspaceDeckEntry(props: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
active: boolean;
|
||||
recoveryRequested: boolean;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
return (
|
||||
<HostAuthorityRoute reference={workspaceSelectionAuthority(props.selection)}>
|
||||
<WorkspaceDeckEntryContent {...props} />
|
||||
</HostAuthorityRoute>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDeckEntryContent({
|
||||
selection,
|
||||
active,
|
||||
recoveryRequested,
|
||||
@@ -308,12 +330,14 @@ function WorkspaceDeckEntry({
|
||||
active={active}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={active}
|
||||
recoveryRequested={active && recoveryRequested}
|
||||
/>
|
||||
<WorkspaceOpenIntentBoundary selection={selection} active={active}>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={active}
|
||||
recoveryRequested={active && recoveryRequested}
|
||||
/>
|
||||
</WorkspaceOpenIntentBoundary>
|
||||
</RetainedPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,10 +39,14 @@ export default function Index() {
|
||||
hosts,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
// COMPAT(unboundStartupSelection): added in v0.12, remove after 2027-04-20.
|
||||
// Qualified restore reacquires its origin at the host boundary before proving absence.
|
||||
workspaceSelectionStatus: workspaceSelection?.authority
|
||||
? "unknown"
|
||||
: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
@@ -50,7 +50,8 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
options: [{ id: "high", label: "High" }],
|
||||
},
|
||||
],
|
||||
parentAgentId: null,
|
||||
parent: null,
|
||||
managedRelationship: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
|
||||
|
||||
@@ -943,7 +943,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
panel: {
|
||||
width: 640,
|
||||
height: 560,
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface SheetHeader {
|
||||
}
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: DirectConnectionLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: DirectConnectionLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
|
||||
export interface AddHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -1007,7 +1007,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
panel: {
|
||||
|
||||
@@ -20,54 +20,53 @@ describe("appearanceStyleBoundaryKey", () => {
|
||||
// This is a placement contract against the production JSX, not a simulated native mount.
|
||||
// Native Gesture Handler/Fabric lifetime behavior is verified on the release app.
|
||||
describe("retained native gesture host appearance placement", () => {
|
||||
it.each([
|
||||
["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]],
|
||||
[
|
||||
"./compact-explorer-sidebar-host.tsx",
|
||||
["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"],
|
||||
],
|
||||
] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => {
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(new URL(path, import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let hostsFound = 0;
|
||||
let webKeysFound = 0;
|
||||
function visit(node: ts.Node) {
|
||||
let name: string | undefined;
|
||||
if (ts.isIdentifier(node)) name = node.text;
|
||||
if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source);
|
||||
if (name && hosts.some((host) => host === name)) {
|
||||
hostsFound++;
|
||||
let webOnly = false;
|
||||
let keyed = false;
|
||||
for (
|
||||
let child = node, parent = node.parent;
|
||||
parent;
|
||||
child = parent, parent = parent.parent
|
||||
) {
|
||||
if (
|
||||
ts.isIfStatement(parent) &&
|
||||
parent.expression.getText(source) === "isWeb" &&
|
||||
parent.thenStatement === child
|
||||
)
|
||||
webOnly = true;
|
||||
if (
|
||||
ts.isJsxElement(parent) &&
|
||||
parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary"
|
||||
)
|
||||
keyed = true;
|
||||
it.each([["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"], true]] as const)(
|
||||
"keeps native hosts outside appearance keys in %s",
|
||||
(path, hosts, hasWebKeys) => {
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(new URL(path, import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let hostsFound = 0;
|
||||
let webKeysFound = 0;
|
||||
function visit(node: ts.Node) {
|
||||
let name: string | undefined;
|
||||
if (ts.isIdentifier(node)) name = node.text;
|
||||
if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source);
|
||||
if (name && hosts.some((host) => host === name)) {
|
||||
hostsFound++;
|
||||
let webOnly = false;
|
||||
let keyed = false;
|
||||
for (
|
||||
let child = node, parent = node.parent;
|
||||
parent;
|
||||
child = parent, parent = parent.parent
|
||||
) {
|
||||
if (
|
||||
ts.isIfStatement(parent) &&
|
||||
parent.expression.getText(source) === "isWeb" &&
|
||||
parent.thenStatement === child
|
||||
)
|
||||
webOnly = true;
|
||||
if (
|
||||
ts.isJsxElement(parent) &&
|
||||
parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary"
|
||||
)
|
||||
keyed = true;
|
||||
}
|
||||
if (keyed && webOnly) webKeysFound++;
|
||||
expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (keyed && webOnly) webKeysFound++;
|
||||
expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false);
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
expect(hostsFound).toBeGreaterThan(0);
|
||||
expect(webKeysFound).toBeGreaterThan(0);
|
||||
});
|
||||
visit(source);
|
||||
expect(hostsFound).toBeGreaterThan(0);
|
||||
if (hasWebKeys) expect(webKeysFound).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(
|
||||
overrides: Partial<CompactExplorerSidebarHostModel> = {},
|
||||
): CompactExplorerSidebarHostModel {
|
||||
return {
|
||||
serverId: overrides.serverId ?? "server-1",
|
||||
workspaceId: overrides.workspaceId ?? "workspace-a",
|
||||
persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a",
|
||||
workspaceRoot: overrides.workspaceRoot ?? "/repo/a",
|
||||
isGit: overrides.isGit ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveCompactExplorerSidebarHostModel", () => {
|
||||
it("retains the last workspace root for the same active selection while the workspace reloads", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/a",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("switches ownership to the active workspace instead of leaking the previous one", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-b" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
persistenceKey: "server-1:workspace-b",
|
||||
workspaceRoot: "",
|
||||
isGit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retain a previous owner when there is no active workspace selection", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: createModel(),
|
||||
selection: null,
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the current workspace directory when it is available", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: null,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }),
|
||||
isGit: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/current",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export interface CompactExplorerSidebarHostModel {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
persistenceKey: string;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
interface ResolveCompactExplorerSidebarHostModelInput {
|
||||
previous: CompactExplorerSidebarHostModel | null;
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveCompactExplorerSidebarHostModel(
|
||||
input: ResolveCompactExplorerSidebarHostModelInput,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const serverId = trimNonEmpty(input.selection?.serverId);
|
||||
const workspaceId = trimNonEmpty(input.selection?.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!persistenceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousForSelection =
|
||||
input.previous &&
|
||||
input.previous.serverId === serverId &&
|
||||
input.previous.workspaceId === workspaceId
|
||||
? input.previous
|
||||
: null;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
workspaceId,
|
||||
persistenceKey,
|
||||
workspaceRoot:
|
||||
trimNonEmpty(input.workspace?.workspaceDirectory) ??
|
||||
previousForSelection?.workspaceRoot ??
|
||||
"",
|
||||
isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit),
|
||||
};
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
CompactExplorerSidebar,
|
||||
NativeExplorerSidebarDock,
|
||||
} from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
|
||||
interface CompactExplorerOpenGestureSurfaceProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
onOpenExplorer: () => void;
|
||||
}
|
||||
|
||||
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
function CompactExplorerOpenGestureSurface({
|
||||
children,
|
||||
enabled,
|
||||
onOpenExplorer,
|
||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||
const explorerOpenGesture = useOpenFileExplorerGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveCompactExplorerSidebarModel(
|
||||
enabled: boolean,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
|
||||
const { checkoutQuery } = useWorkspaceCheckoutStatus({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused: enabled && selection !== null,
|
||||
normalizedServerId: selection?.serverId ?? "",
|
||||
normalizedWorkspaceId: selection?.workspaceId ?? "",
|
||||
workspaceDirectory: workspace?.workspaceDirectory || null,
|
||||
});
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerActive) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerActive) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
retainedModelRef.current = resolvedModel;
|
||||
}
|
||||
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
presentation: "overlay" | "dock";
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebarHost({
|
||||
children,
|
||||
enabled,
|
||||
presentation,
|
||||
}: CompactExplorerSidebarHostProps) {
|
||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||
const openWorkspaceTabInFocusedPane = useCallback(
|
||||
(workspaceKey: string, target: WorkspaceTabTarget) =>
|
||||
openTab({ workspaceKey, target, intent: "reveal" }),
|
||||
[openTab],
|
||||
);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
return;
|
||||
}
|
||||
openCompactFileExplorer({
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
});
|
||||
}, [model, openCompactFileExplorer]);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath,
|
||||
persistenceKey: model.persistenceKey,
|
||||
closeExplorerAfterOpen: presentation === "overlay",
|
||||
showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
focusWorkspaceTab,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const nextWidth = event.nativeEvent.layout.width;
|
||||
setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
|
||||
}, []);
|
||||
|
||||
const explorer =
|
||||
enabled && model ? (
|
||||
<DiffDocumentWorkspaceCacheProvider key={model.persistenceKey}>
|
||||
{presentation === "dock" ? (
|
||||
<NativeExplorerSidebarDock
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
persistenceKey={model.persistenceKey}
|
||||
containerWidth={containerWidth}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
) : (
|
||||
<CompactExplorerSidebar
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
)}
|
||||
</DiffDocumentWorkspaceCacheProvider>
|
||||
) : null;
|
||||
|
||||
let themedExplorer = explorer;
|
||||
if (isWeb) {
|
||||
themedExplorer = <AppearanceStyleBoundary>{explorer}</AppearanceStyleBoundary>;
|
||||
}
|
||||
|
||||
if (presentation === "dock") {
|
||||
return (
|
||||
<View style={styles.row} onLayout={handleContainerLayout}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
{themedExplorer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompactExplorerOpenGestureSurface
|
||||
enabled={enabled && Boolean(model?.workspaceRoot)}
|
||||
onOpenExplorer={handleOpenExplorer}
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
{themedExplorer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
fill: {
|
||||
flex: 1,
|
||||
},
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
},
|
||||
} as const;
|
||||
@@ -1,574 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { ChangesSurface } from "@/git/diff-pane";
|
||||
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/keyboard/shift";
|
||||
import { shouldUseCompactExplorerKeyboardPadding } from "@/keyboard/shift";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
||||
import { PullRequestContent } from "@/panels/pull-request";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import {
|
||||
EXPLORER_TAB_RAIL_INSET,
|
||||
explorerSidebarCloseButtonLayout,
|
||||
resolveExplorerSidebarWidth,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface ExplorerSidebarSharedState {
|
||||
explorerTab: ExplorerTab;
|
||||
handleTabPress: (tab: ExplorerTab) => void;
|
||||
}
|
||||
|
||||
function useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
}: Pick<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): ExplorerSidebarSharedState {
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||
);
|
||||
|
||||
return { explorerTab, handleTabPress };
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isActive = useIsMobilePanelActive("file-explorer");
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const usePanelKeyboardPadding = shouldUseCompactExplorerKeyboardPadding({ isGit, explorerTab });
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: usePanelKeyboardPadding,
|
||||
});
|
||||
const { gesture: closeGesture } = useCloseFileExplorerGesture();
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen: isActive,
|
||||
});
|
||||
showMobileAgent();
|
||||
},
|
||||
[isActive, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
|
||||
const mobileSidebarStyle = useMemo(
|
||||
() => [
|
||||
{
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
paddingBottom: usePanelKeyboardPadding ? 0 : insets.bottom,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
mobileKeyboardInsetStyle,
|
||||
],
|
||||
[
|
||||
insets.bottom,
|
||||
insets.top,
|
||||
mobileKeyboardInsetStyle,
|
||||
theme.colors.surfaceSidebar,
|
||||
usePanelKeyboardPadding,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isActive}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarStyle}
|
||||
>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isActive}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps {
|
||||
persistenceKey: string;
|
||||
containerWidth: number;
|
||||
}
|
||||
|
||||
export function NativeExplorerSidebarDock({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
persistenceKey,
|
||||
containerWidth,
|
||||
}: NativeExplorerSidebarDockProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const storedWidth = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerSidebarWidthByWorkspace[persistenceKey],
|
||||
);
|
||||
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
|
||||
const visibleWidth = resolveExplorerSidebarWidth({
|
||||
requestedWidth: storedWidth,
|
||||
containerWidth,
|
||||
});
|
||||
const resizeWidth = useSharedValue(visibleWidth);
|
||||
const startWidthRef = useRef(visibleWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleWidth;
|
||||
}, [resizeWidth, visibleWidth]);
|
||||
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
const commitWidth = useCallback(
|
||||
(width: number) => resizeExplorerSidebar(persistenceKey, width),
|
||||
[persistenceKey, resizeExplorerSidebar],
|
||||
);
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onBegin(() => scheduleOnRN(showResizeGrip))
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleWidth + event.translationX;
|
||||
resizeWidth.value = visibleWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
resizeWidth.value = resolveExplorerSidebarWidth({
|
||||
requestedWidth: startWidthRef.current - event.translationX,
|
||||
containerWidth,
|
||||
});
|
||||
})
|
||||
.onEnd(() => runOnJS(commitWidth)(resizeWidth.value))
|
||||
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||
[commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth],
|
||||
);
|
||||
const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
||||
const dockStyle = useMemo(
|
||||
() => [
|
||||
styles.nativeDock,
|
||||
{
|
||||
display: isOpen ? ("flex" as const) : ("none" as const),
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
animatedWidthStyle,
|
||||
],
|
||||
[animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar],
|
||||
);
|
||||
const dockContentStyle = useMemo(
|
||||
() => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }],
|
||||
[theme.colors.border],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
|
||||
<View style={dockContentStyle}>
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="native-explorer-sidebar-resize-handle"
|
||||
/>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={showMobileAgent}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExplorerTabButtonProps {
|
||||
tab: ExplorerTab;
|
||||
active: boolean;
|
||||
label?: string;
|
||||
onTabPress: (tab: ExplorerTab) => void;
|
||||
testID: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ExplorerTabButton({
|
||||
tab,
|
||||
active,
|
||||
label,
|
||||
onTabPress,
|
||||
testID,
|
||||
children,
|
||||
}: ExplorerTabButtonProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
|
||||
const tabStyle = useMemo(
|
||||
() => [styles.tab(isCompact), active && styles.tabActive],
|
||||
[active, isCompact],
|
||||
);
|
||||
const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]);
|
||||
return (
|
||||
<Pressable testID={testID} style={tabStyle} onPress={handlePress}>
|
||||
{children}
|
||||
{label !== undefined ? <Text style={tabTextStyle}>{label}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
activeTab: ExplorerTab;
|
||||
onTabPress: (tab: ExplorerTab) => void;
|
||||
onClose: () => void;
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
function ExplorerSidebarContent({
|
||||
activeTab,
|
||||
onTabPress,
|
||||
onClose,
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const closeButtonLayout = explorerSidebarCloseButtonLayout(isCompact);
|
||||
const closeButtonStyle = useMemo(
|
||||
() => ({ width: closeButtonLayout.size, height: closeButtonLayout.size }),
|
||||
[closeButtonLayout.size],
|
||||
);
|
||||
// The close glyph shares the trailing rail with the toolbar rows below it.
|
||||
const headerRightSectionStyle = useMemo(
|
||||
() => [styles.headerRightSection, { paddingRight: closeButtonLayout.trailingPadding }],
|
||||
[closeButtonLayout.trailingPadding],
|
||||
);
|
||||
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
isGit,
|
||||
requested: activeTab === "pr",
|
||||
enabled: isOpen,
|
||||
timelineEnabled: activeTab === "pr",
|
||||
});
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const availableTabs = useMemo<ExplorerTab[]>(() => {
|
||||
const tabs: ExplorerTab[] = isGit ? ["changes", "files"] : ["files"];
|
||||
if (isGit && showPrTab) tabs.push("pr");
|
||||
return tabs;
|
||||
}, [isGit, showPrTab]);
|
||||
const { mountedTabIds } = useMountedTabSet({
|
||||
activeTabId: resolvedTab,
|
||||
allTabIds: availableTabs,
|
||||
cap: availableTabs.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<WindowChromeSafeArea placement="inline" style={styles.header} testID="explorer-header">
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabsContainer(isCompact)}>
|
||||
{isGit && (
|
||||
<ExplorerTabButton
|
||||
tab="changes"
|
||||
active={resolvedTab === "changes"}
|
||||
label={t("workspace.tabs.explorerSidebar.changes")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-changes"
|
||||
/>
|
||||
)}
|
||||
<ExplorerTabButton
|
||||
tab="files"
|
||||
active={resolvedTab === "files"}
|
||||
label={t("workspace.tabs.explorerSidebar.files")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
{isGit && showPrTab && (
|
||||
<ExplorerTabButton
|
||||
tab="pr"
|
||||
active={resolvedTab === "pr"}
|
||||
label={prTabLabel}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-pr"
|
||||
>
|
||||
<PullRequestTabIcon
|
||||
forge={prPane.forge}
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
</ExplorerTabButton>
|
||||
)}
|
||||
</View>
|
||||
<View style={headerRightSectionStyle}>
|
||||
<ToolbarButton
|
||||
compact={isCompact}
|
||||
style={closeButtonStyle}
|
||||
hitSlop={closeButtonLayout.hitSlop}
|
||||
label={t("workspace.tabs.explorerSidebar.close")}
|
||||
onPress={onClose}
|
||||
testID="explorer-close"
|
||||
nativeID="explorer-close"
|
||||
>
|
||||
<ThemedX size={closeButtonLayout.iconSize} uniProps={mutedIconColorMapping} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{mountedTabIds.has("changes") ? (
|
||||
<RetainedPanel active={resolvedTab === "changes"}>
|
||||
<ChangedFilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
) : null}
|
||||
{mountedTabIds.has("files") ? (
|
||||
<RetainedPanel active={resolvedTab === "files"}>
|
||||
<FilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
) : null}
|
||||
{mountedTabIds.has("pr") ? (
|
||||
<RetainedPanel active={resolvedTab === "pr"}>
|
||||
<PrTabContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
prPane={prPane}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangedFilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: Pick<
|
||||
SidebarContentProps,
|
||||
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||
>) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
const [changesState, setChangesState] = useState<ChangesState>(() =>
|
||||
changesStateSchema.parse(defaultChangesState),
|
||||
);
|
||||
return (
|
||||
<ChangesSurface
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
state={changesState}
|
||||
onStateChange={setChangesState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PrTabContent = PullRequestContent;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeDock: {
|
||||
position: "relative",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
nativeDockContent: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderLeftWidth: 1,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
position: "relative",
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
},
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
tabsContainer: (isCompact: boolean) => ({
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
// With the tab's own horizontal padding this puts the label on the pane's
|
||||
// leading rail, the same way the desktop tab rail does.
|
||||
paddingLeft: isCompact ? EXPLORER_TAB_RAIL_INSET : theme.spacing[2],
|
||||
}),
|
||||
tab: (isCompact: boolean) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: isCompact ? theme.spacing[2] : theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
}),
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
tabTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
tabTextMuted: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
headerRightSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
contentArea: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
|
||||
const labels = {
|
||||
hostRequired: "host required",
|
||||
invalidPort: "invalid port",
|
||||
invalidConnection: "invalid connection",
|
||||
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
|
||||
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
|
||||
timedOut: "timeout",
|
||||
refused: "refused",
|
||||
hostNotFound: "DNS failure",
|
||||
hostUnreachable: "unreachable",
|
||||
tlsError: "TLS failure",
|
||||
unableToConnect: "connection failure",
|
||||
};
|
||||
|
||||
function failure(message: string) {
|
||||
return buildConnectionFailureCopy({
|
||||
endpoint: "tcp://example.test:443?ssl=true",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: "transport error",
|
||||
lastError: message,
|
||||
}),
|
||||
labels,
|
||||
});
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.tlsError,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["certificate not found", labels.tlsError],
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
["EHOSTUNREACH", labels.hostUnreachable],
|
||||
["unexpected transport failure", labels.unableToConnect],
|
||||
])("classifies %s", (message, expected) => {
|
||||
expect(failure(message).detail).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves authentication failures and their technical details", () => {
|
||||
const message = "Invalid daemon password";
|
||||
expect(
|
||||
buildConnectionFailureCopy({
|
||||
endpoint: "example.test:6767",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: message,
|
||||
lastError: message,
|
||||
authFailureReason: "incorrect_password",
|
||||
}),
|
||||
labels,
|
||||
}),
|
||||
).toMatchObject({ detail: message, raw: message });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
|
||||
interface ConnectionFailureLabels {
|
||||
failedToConnect: (endpoint: string) => string;
|
||||
noAdditionalDetails: (detail: string) => string;
|
||||
timedOut: string;
|
||||
refused: string;
|
||||
hostNotFound: string;
|
||||
hostUnreachable: string;
|
||||
tlsError: string;
|
||||
unableToConnect: string;
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: ConnectionFailureLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
export function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: ConnectionFailureLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
rawLower.includes("trust anchor") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
serverId: string;
|
||||
@@ -150,7 +151,7 @@ export function ContextWindowMeter({
|
||||
showPercentage = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
||||
const showsUsage = useHostReportsUsage(serverId);
|
||||
@@ -192,7 +193,9 @@ export function ContextWindowMeter({
|
||||
);
|
||||
const percentageLabel =
|
||||
showPercentage && context ? (
|
||||
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
|
||||
<Text style={styles.percentageLabel}>
|
||||
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
|
||||
</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = context
|
||||
? t("contextWindow.accessibility", { percentage: context.percentage })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
|
||||
describe("context window meter formatting", () => {
|
||||
it("localizes percentages without hiding context overflow", () => {
|
||||
expect(formatContextPercentage(125, "en")).toBe("125%");
|
||||
expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,10 @@
|
||||
export function formatContextPercentage(value: number, locale?: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value / 100);
|
||||
}
|
||||
|
||||
export function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
|
||||
@@ -101,3 +101,25 @@ describe("desktop sidebar layout", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves room for Explorer and the workspace before showing app navigation", () => {
|
||||
const contentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
explorerVisible: true,
|
||||
});
|
||||
expect(contentMinimumWidth).toBe(600);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth,
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 744,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth,
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 951,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
|
||||
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
|
||||
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
|
||||
@@ -62,8 +63,12 @@ export function resolveDesktopSidebarWidth(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopAppContentMinimum(input: { isSettingsRoute: boolean }): number {
|
||||
return input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0;
|
||||
export function resolveDesktopAppContentMinimum(input: {
|
||||
isSettingsRoute: boolean;
|
||||
explorerVisible?: boolean;
|
||||
}): number {
|
||||
if (input.isSettingsRoute) return SETTINGS_DESKTOP_SPLIT_MIN_WIDTH;
|
||||
return input.explorerVisible ? EXPLORER_WORKSPACE_MIN_WIDTH : 0;
|
||||
}
|
||||
|
||||
export function canDesktopAppSidebarShare(input: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
explorerSidebarCloseButtonLayout,
|
||||
resolveExplorerSidebarDockSizes,
|
||||
resolveExplorerSidebarWidth,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
@@ -17,24 +16,7 @@ describe("Explorer sidebar layout", () => {
|
||||
it("has no fixed maximum while preserving room for the workspace body", () => {
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Explorer close action", () => {
|
||||
it("preserves the wide-native touch target and glyph rail", () => {
|
||||
const layout = explorerSidebarCloseButtonLayout(false);
|
||||
expect(layout.size).toBe(34);
|
||||
expect(layout.iconSize).toBe(18);
|
||||
expect(layout.size + layout.hitSlop * 2).toBe(50);
|
||||
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the compact close glyph on the pane rail", () => {
|
||||
const layout = explorerSidebarCloseButtonLayout(true);
|
||||
expect(layout.size).toBe(32);
|
||||
expect(layout.iconSize).toBe(18);
|
||||
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(8);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,13 @@
|
||||
import {
|
||||
paneContentToolbarIconSize,
|
||||
paneContentToolbarTrailingPadding,
|
||||
} from "@/components/ui/pane-content-toolbar";
|
||||
import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome";
|
||||
|
||||
export function explorerSidebarCloseButtonLayout(compact: boolean) {
|
||||
// The inline native dock retains its padded close action and touch slop.
|
||||
if (!compact) {
|
||||
return { size: 34, iconSize: 18, hitSlop: 8, trailingPadding: 8 };
|
||||
}
|
||||
return {
|
||||
size: smallIconButtonChromeFrameSize(compact),
|
||||
iconSize: paneContentToolbarIconSize(compact),
|
||||
hitSlop: 0,
|
||||
trailingPadding: paneContentToolbarTrailingPadding(compact, "glyph"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer inset of an Explorer tab rail. With each tab's own horizontal padding it puts
|
||||
* the tab label on the pane's shared leading rail (`treeRowPaddingLeft(0)`).
|
||||
*/
|
||||
export const EXPLORER_TAB_RAIL_INSET = 4;
|
||||
|
||||
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
|
||||
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
|
||||
const MIN_WORKSPACE_BODY_WIDTH = 400;
|
||||
const MIN_WORKSPACE_BODY_WIDTH = 360;
|
||||
export const EXPLORER_WORKSPACE_MIN_WIDTH = MIN_EXPLORER_SIDEBAR_WIDTH + MIN_WORKSPACE_BODY_WIDTH;
|
||||
|
||||
export function resolveExplorerSidebarWidth(input: {
|
||||
requestedWidth?: number;
|
||||
containerWidth: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
||||
const maximumVisibleWidth =
|
||||
input.containerWidth > 0
|
||||
|
||||
@@ -46,7 +46,7 @@ export function FileDropBackdrop() {
|
||||
|
||||
const positionStyles = RNStyleSheet.create({
|
||||
overlay: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
...RNStyleSheet.absoluteFill,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
@@ -55,7 +55,7 @@ const positionStyles = RNStyleSheet.create({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
@@ -52,8 +52,15 @@ export function CompactHeader({
|
||||
}: CompactHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const rootStyle = useMemo(
|
||||
() => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
|
||||
[insets.top],
|
||||
() => [
|
||||
styles.root,
|
||||
{
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
marginLeft: insets.left,
|
||||
marginRight: insets.right,
|
||||
},
|
||||
],
|
||||
[insets.top, insets.left, insets.right],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -679,7 +679,9 @@ function DesktopSidebar({
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const viewportWidth = windowWidth - insets.left - insets.right;
|
||||
const visibleSidebarWidth = resolveDesktopSidebarWidth({
|
||||
requestedWidth: sidebarWidth,
|
||||
viewportWidth,
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { RewindMode } from "./use-rewind-capabilities";
|
||||
import { useRewindComposerRestore } from "./composer-restore";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStoreApi } from "@/stores/session-store";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
interface UseRewindAgentMutationInput {
|
||||
serverId?: string;
|
||||
@@ -26,6 +26,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
isPending: boolean;
|
||||
} {
|
||||
const toast = useToast();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const { t } = useTranslation();
|
||||
const composerRestore = useRewindComposerRestore();
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
@@ -36,12 +38,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||
? sessionStore.getState().sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||
: undefined;
|
||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
await runtime.fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
|
||||
@@ -2761,7 +2761,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
},
|
||||
workspaceArchivingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: `${theme.colors.surface0}cc`,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
@@ -11,35 +12,14 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { View, Text, type LayoutChangeEvent } from "react-native";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import {
|
||||
resolveExplorerSidebarDockSizes,
|
||||
resolveExplorerSidebarWidth,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
hasMultipleVisiblePanes,
|
||||
@@ -48,41 +28,26 @@ import {
|
||||
} from "@/components/split-container-focus";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import {
|
||||
removeWindowChromeCorner,
|
||||
WindowChromeRegion,
|
||||
WindowChromeSafeArea,
|
||||
useWindowChromeCorners,
|
||||
removeWindowChromeCorner,
|
||||
type WindowChromeCorners,
|
||||
} from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
type TabDropPreview,
|
||||
} from "@/components/split-container-tab-drop-preview";
|
||||
import {
|
||||
SplitDropZone,
|
||||
resolveSplitDropPosition,
|
||||
type SplitDropZoneHover,
|
||||
} from "@/components/split-drop-zone";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { type TabDropPreview } from "@/components/split-container-tab-drop-preview";
|
||||
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
|
||||
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
collectAllPanes,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -90,14 +55,11 @@ import {
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
renderMainHeader?: () => ReactNode;
|
||||
renderExplorerSidebarHeaderAction?: () => ReactNode;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -140,41 +102,6 @@ interface SplitContainerProps {
|
||||
onExitFocusMode: () => void;
|
||||
}
|
||||
|
||||
interface WorkspaceTabDragData {
|
||||
kind: "workspace-tab";
|
||||
paneId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneDropData {
|
||||
kind: "split-pane-drop";
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
const EMPTY_SPLIT_NODES: SplitNode[] = [];
|
||||
const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar";
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
|
||||
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
|
||||
return (
|
||||
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
|
||||
);
|
||||
}
|
||||
|
||||
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
|
||||
return isWorkspaceTabDragData(data) ? data : undefined;
|
||||
}
|
||||
|
||||
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
|
||||
if (isWorkspaceTabDragData(data)) return data;
|
||||
if (isSplitPaneDropData(data)) return data;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface SplitNodeViewProps extends Omit<
|
||||
SplitContainerProps,
|
||||
"layout" | "onMoveTabToPane" | "onSelectTabInPane"
|
||||
@@ -212,105 +139,12 @@ interface SplitPaneViewProps extends Omit<
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
}
|
||||
|
||||
interface DragMoveRects {
|
||||
translatedRect: { left: number; top: number; width: number; height: number };
|
||||
overRect: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
function resolveDragMoveRects(
|
||||
event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
|
||||
): DragMoveRects | null {
|
||||
const translatedRect = event.active.rect.current.translated;
|
||||
const overRect = event.over?.rect;
|
||||
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { translatedRect, overRect };
|
||||
}
|
||||
|
||||
function computeTabOverDropPreview(input: {
|
||||
activeData: WorkspaceTabDragData;
|
||||
overData: WorkspaceTabDragData;
|
||||
rects: DragMoveRects;
|
||||
panesById: Map<string, SplitPane>;
|
||||
uiTabs: WorkspaceTab[];
|
||||
}): TabDropPreview | null {
|
||||
const { activeData, overData, rects, panesById, uiTabs } = input;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!targetPane) {
|
||||
return null;
|
||||
}
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
return computeTabDropPreview({
|
||||
activePaneId: activeData.paneId,
|
||||
activeTabId: activeData.tabId,
|
||||
overPaneId: overData.paneId,
|
||||
overTabId: overData.tabId,
|
||||
targetTabs,
|
||||
activeRect: {
|
||||
left: rects.translatedRect.left,
|
||||
width: rects.translatedRect.width,
|
||||
},
|
||||
overRect: {
|
||||
left: rects.overRect.left,
|
||||
width: rects.overRect.width,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function computePaneOverDropPreview(input: {
|
||||
overData: SplitPaneDropData;
|
||||
rects: DragMoveRects;
|
||||
}): SplitDropZoneHover | null {
|
||||
const { overData, rects } = input;
|
||||
const centerX = rects.translatedRect.left + rects.translatedRect.width / 2;
|
||||
const centerY = rects.translatedRect.top + rects.translatedRect.height / 2;
|
||||
const relativeX = centerX - rects.overRect.left;
|
||||
const relativeY = centerY - rects.overRect.top;
|
||||
if (
|
||||
Number.isNaN(relativeX) ||
|
||||
Number.isNaN(relativeY) ||
|
||||
relativeX < 0 ||
|
||||
relativeX > rects.overRect.width ||
|
||||
relativeY < 0 ||
|
||||
relativeY > rects.overRect.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
paneId: overData.paneId,
|
||||
position: resolveSplitDropPosition({
|
||||
width: rects.overRect.width,
|
||||
height: rects.overRect.height,
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||
const pointerHits = pointerWithin(args);
|
||||
const tabHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
|
||||
);
|
||||
if (tabHits.length > 0) {
|
||||
return tabHits;
|
||||
}
|
||||
|
||||
const paneHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
|
||||
);
|
||||
if (paneHits.length > 0) {
|
||||
return paneHits;
|
||||
}
|
||||
|
||||
return closestCenter(args);
|
||||
};
|
||||
const EMPTY_SPLIT_NODES: SplitNode[] = [];
|
||||
const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
|
||||
export function SplitContainer({
|
||||
layout,
|
||||
renderMainHeader,
|
||||
renderExplorerSidebarHeaderAction,
|
||||
workspaceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -335,8 +169,6 @@ export function SplitContainer({
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onSelectTabInPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
focusModeEnabled,
|
||||
@@ -344,9 +176,7 @@ export function SplitContainer({
|
||||
}: 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);
|
||||
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
|
||||
const [maximizedPane, setMaximizedPane] = useState<{
|
||||
workspaceKey: string;
|
||||
paneId: string;
|
||||
@@ -357,22 +187,6 @@ export function SplitContainer({
|
||||
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
||||
const explorerSidebarPane = useMemo(
|
||||
() => findPaneById(layout.root, explorerSidebarPaneId),
|
||||
[layout.root, explorerSidebarPaneId],
|
||||
);
|
||||
const mainRoot = useMemo(
|
||||
() => removePaneFromSplitTree(layout.root, explorerSidebarPaneId),
|
||||
[layout.root, explorerSidebarPaneId],
|
||||
@@ -408,434 +222,68 @@ export function SplitContainer({
|
||||
}),
|
||||
[focusModeEnabled, layout.focusedPaneId, mainRoot],
|
||||
);
|
||||
const storedExplorerSidebarWidth = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerSidebarWidthByWorkspace[workspaceKey],
|
||||
);
|
||||
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
|
||||
const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0);
|
||||
const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth;
|
||||
const explorerSidebarWidth = resolveExplorerSidebarWidth({
|
||||
requestedWidth: requestedExplorerSidebarWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
});
|
||||
const explorerSidebarDockSizes = useMemo(
|
||||
() =>
|
||||
resolveExplorerSidebarDockSizes({
|
||||
requestedWidth: requestedExplorerSidebarWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
}),
|
||||
[requestedExplorerSidebarWidth, workspaceShellWidth],
|
||||
);
|
||||
const renderExplorerSidebarDock = Boolean(
|
||||
!focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true,
|
||||
);
|
||||
const mainColumnWindowChromeCorners = renderExplorerSidebarDock
|
||||
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
|
||||
: inheritedWindowChromeCorners;
|
||||
const explorerPane = explorerSidebarPaneId
|
||||
? collectAllPanes(layout.root).find((pane) => pane.id === explorerSidebarPaneId)
|
||||
: null;
|
||||
const mainColumnWindowChromeCorners =
|
||||
!focusModeEnabled && explorerPane && !explorerPane.hidden
|
||||
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
|
||||
: inheritedWindowChromeCorners;
|
||||
const mainColumnStyle = styles.mainColumn;
|
||||
const explorerSidebarDockStyle = useMemo(
|
||||
() => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })],
|
||||
[explorerSidebarWidth],
|
||||
);
|
||||
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const nextWidth = event.nativeEvent.layout.width;
|
||||
setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth));
|
||||
}, []);
|
||||
const previewExplorerSidebarResize = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
const nextRatio = sizes[1];
|
||||
if (nextRatio !== undefined) {
|
||||
setPreviewExplorerSidebarWidth(
|
||||
resolveExplorerSidebarWidth({
|
||||
requestedWidth: nextRatio * workspaceShellWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[workspaceShellWidth],
|
||||
);
|
||||
const commitExplorerSidebarResize = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
setPreviewExplorerSidebarWidth(null);
|
||||
const nextRatio = sizes[1];
|
||||
if (nextRatio !== undefined) {
|
||||
resizeExplorerSidebar(
|
||||
workspaceKey,
|
||||
resolveExplorerSidebarWidth({
|
||||
requestedWidth: nextRatio * workspaceShellWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[resizeExplorerSidebar, workspaceKey, workspaceShellWidth],
|
||||
);
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
setActiveDragTabId(data.tabId);
|
||||
}, []);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
}, []);
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
if (activeData?.kind !== "workspace-tab") {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rects = resolveDragMoveRects(event);
|
||||
if (!rects) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
|
||||
const destinationPaneId =
|
||||
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
|
||||
? overData.paneId
|
||||
: null;
|
||||
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
|
||||
if (
|
||||
!activeTab ||
|
||||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
|
||||
) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const preview = computeTabOverDropPreview({
|
||||
activeData,
|
||||
overData,
|
||||
rects,
|
||||
panesById,
|
||||
uiTabs,
|
||||
});
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(preview);
|
||||
return;
|
||||
}
|
||||
|
||||
setTabDropPreview(null);
|
||||
if (overData?.kind !== "split-pane-drop") {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDropPreview(computePaneOverDropPreview({ overData, rects }));
|
||||
},
|
||||
[normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
|
||||
);
|
||||
|
||||
const applyTabDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
const sourcePane = panesById.get(activeData.paneId) ?? null;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!sourcePane || !targetPane) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||
const resolvedTabDropPreview =
|
||||
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
|
||||
if (sourceIndex < 0 || !resolvedTabDropPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeData.paneId === overData.paneId) {
|
||||
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||
const nextTabs = arrayMove(
|
||||
sourceTabs,
|
||||
sourceIndex,
|
||||
resolvedTabDropPreview.insertionIndex,
|
||||
);
|
||||
onReorderTabsInPane(
|
||||
activeData.paneId,
|
||||
nextTabs.map((tab) => tab.tabId),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
|
||||
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
||||
},
|
||||
[onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs],
|
||||
);
|
||||
|
||||
const applyPaneDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
if (dropPreview?.paneId !== overData.paneId) {
|
||||
return;
|
||||
}
|
||||
if (dropPreview.position === "center") {
|
||||
if (activeData.paneId !== overData.paneId) {
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSplitPane({
|
||||
tabId: activeData.tabId,
|
||||
targetPaneId: overData.paneId,
|
||||
position: dropPreview.position,
|
||||
});
|
||||
},
|
||||
[dropPreview, onMoveTabToPane, onSplitPane],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
if (activeData?.kind === "workspace-tab" && event.over) {
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
applyTabDropEnd({ activeData, overData });
|
||||
} else if (overData?.kind === "split-pane-drop") {
|
||||
applyPaneDropEnd({ activeData, overData });
|
||||
}
|
||||
}
|
||||
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
},
|
||||
[applyTabDropEnd, applyPaneDropEnd],
|
||||
);
|
||||
|
||||
return (
|
||||
<RenderProfile id="SplitContainer">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={dropCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={updateDropPreview}
|
||||
onDragOver={updateDropPreview}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<View style={styles.workspaceShell} onLayout={handleWorkspaceShellLayout}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{renderMainHeader?.()}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
node={renderRoot}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
{renderExplorerSidebarDock && explorerSidebarPane ? (
|
||||
<>
|
||||
<ResizeHandle
|
||||
testID="workspace-explorer-sidebar-resize-handle"
|
||||
direction="horizontal"
|
||||
hitAreaAlignment="end"
|
||||
groupId={EXPLORER_SIDEBAR_RESIZE_GROUP_ID}
|
||||
index={0}
|
||||
sizes={explorerSidebarDockSizes}
|
||||
containerSize={workspaceShellWidth}
|
||||
onPreviewResizeSplit={previewExplorerSidebarResize}
|
||||
onResizeSplit={commitExplorerSidebarResize}
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{renderMainHeader?.()}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
node={renderRoot}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
<View style={explorerSidebarDockStyle}>
|
||||
<ExplorerSidebarDock
|
||||
pane={explorerSidebarPane}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
closingTabIds={closingTabIds}
|
||||
onSelectTab={onSelectTabInPane}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreview={tabDropPreview}
|
||||
headerAction={renderExplorerSidebarHeaderAction?.()}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
<DragOverlayTabChip
|
||||
tabId={activeDragTabId}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChip({
|
||||
tabId,
|
||||
uiTabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tabId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const tab = uiTabs.find((t) => t.tabId === tabId);
|
||||
const descriptor = useMemo<WorkspaceTabDescriptor | null>(
|
||||
() =>
|
||||
tab
|
||||
? {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
}
|
||||
: null,
|
||||
[tab],
|
||||
);
|
||||
if (!descriptor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DragOverlayTabChipInner
|
||||
tab={descriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChipInner({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const chipStyle = useMemo(
|
||||
() => [
|
||||
styles.dragOverlayChip,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
],
|
||||
[theme.colors.surface1, theme.colors.borderAccent],
|
||||
);
|
||||
const chipLabelStyle = useMemo(
|
||||
() => [styles.dragOverlayLabel, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const label =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
|
||||
return (
|
||||
<View style={chipStyle}>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
|
||||
<Text numberOfLines={1} style={chipLabelStyle}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
</WindowChromeRegion>
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1317,21 +765,6 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
);
|
||||
});
|
||||
|
||||
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
||||
const next = new Map<string, SplitPane>();
|
||||
function visit(current: SplitNode) {
|
||||
if (current.kind === "pane") {
|
||||
next.set(current.pane.id, current.pane);
|
||||
return;
|
||||
}
|
||||
for (const child of current.group.children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
visit(node);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getNodeKey(node: SplitNode): string {
|
||||
if (node.kind === "pane") {
|
||||
return node.pane.id;
|
||||
@@ -1396,12 +829,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
explorerSidebarDock: {
|
||||
flexShrink: 0,
|
||||
minWidth: 240,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
group: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
@@ -1436,18 +863,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
dragOverlayChip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
maxWidth: 200,
|
||||
},
|
||||
dragOverlayLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -147,7 +147,7 @@ function getPreviewFrameStyle(position: SplitDropZonePosition) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
zIndex: 40,
|
||||
},
|
||||
previewOverlay: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnUI } from "react-native-worklets";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { getStatusRingRotation } from "@/components/status-ring/geometry";
|
||||
|
||||
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
|
||||
const activeRingCount = makeMutable(0);
|
||||
const clockRunning = makeMutable(false);
|
||||
let nextListenerId = 1;
|
||||
|
||||
function advanceSharedRotation(): void {
|
||||
"worklet";
|
||||
@@ -18,13 +20,21 @@ function advanceSharedRotation(): void {
|
||||
requestAnimationFrame(advanceSharedRotation);
|
||||
}
|
||||
|
||||
function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
function registerStatusRing(
|
||||
rotation: SharedValue<number>,
|
||||
registered: SharedValue<boolean>,
|
||||
listenerId: number,
|
||||
): void {
|
||||
"worklet";
|
||||
if (registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
rotation.value = getStatusRingRotation(Date.now());
|
||||
sharedRotation.addListener(listenerId, (value) => {
|
||||
rotation.value = value;
|
||||
});
|
||||
activeRingCount.value += 1;
|
||||
|
||||
if (!clockRunning.value) {
|
||||
@@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>): void {
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
|
||||
"worklet";
|
||||
if (!registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = false;
|
||||
sharedRotation.removeListener(listenerId);
|
||||
activeRingCount.value -= 1;
|
||||
}
|
||||
|
||||
export function useStatusRingRotation(): SharedValue<number> {
|
||||
const active = useRetainedPanelActive();
|
||||
// Hidden retained views must not subscribe their animated styles to the shared clock.
|
||||
const rotation = useSharedValue(getStatusRingRotation(Date.now()));
|
||||
const registered = useSharedValue(false);
|
||||
const [listenerId] = useState(() => nextListenerId++);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleOnUI(registerStatusRing, registered);
|
||||
if (!active) return;
|
||||
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
|
||||
return () => {
|
||||
scheduleOnUI(unregisterStatusRing, registered);
|
||||
scheduleOnUI(unregisterStatusRing, registered, listenerId);
|
||||
};
|
||||
}, [registered]);
|
||||
}, [active, listenerId, registered, rotation]);
|
||||
|
||||
return sharedRotation;
|
||||
return rotation;
|
||||
}
|
||||
|
||||
@@ -1171,7 +1171,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
attachOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.16)",
|
||||
|
||||
@@ -1765,7 +1765,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
desktopOverlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
desktopBackdrop: {
|
||||
|
||||
@@ -81,6 +81,6 @@ export function FloatingPanelPortalHost({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
host: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,13 +14,16 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import {
|
||||
@@ -326,28 +329,31 @@ function HoverCardSurface({
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
let cancelled = false;
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect(rect);
|
||||
void Promise.all([
|
||||
measureElement(triggerRef.current),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([triggerRect, hostRect]) => {
|
||||
if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect });
|
||||
return undefined;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [triggerRef]);
|
||||
}, [portalHostName, triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRect || !contentSize) return;
|
||||
if (!anchor || !contentSize) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y } = computePosition({
|
||||
triggerRect,
|
||||
triggerRect: anchor.triggerRect,
|
||||
contentSize,
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
placement,
|
||||
@@ -355,8 +361,8 @@ function HoverCardSurface({
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
setPosition({ x, y });
|
||||
}, [alignment, contentSize, offset, placement, triggerRect]);
|
||||
setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y });
|
||||
}, [alignment, anchor, contentSize, offset, placement]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
@@ -377,7 +383,7 @@ function HoverCardSurface({
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<Portal hostName={portalHostName}>
|
||||
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
|
||||
@@ -86,6 +86,6 @@ const styles = StyleSheet.create({
|
||||
minHeight: 0,
|
||||
},
|
||||
host: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -527,7 +527,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
overlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
backdrop: {
|
||||
|
||||
@@ -66,9 +66,9 @@ export const PressHighlight = forwardRef<View, PressHighlightProps>(function Pre
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
highlight: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
highlightFill: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 0,
|
||||
},
|
||||
badgeBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
opacity: theme.opacity[50],
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TabDropPreview } from "./split-container-tab-drop-preview";
|
||||
import type { SplitDropZoneHover } from "./split-drop-zone";
|
||||
export const WorkspaceTabDragContext = createContext<{
|
||||
activeDragTabId: string | null;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
externalDndContext: boolean;
|
||||
}>({ activeDragTabId: null, dropPreview: null, tabDropPreview: null, externalDndContext: false });
|
||||
export function useWorkspaceTabDrag() {
|
||||
return useContext(WorkspaceTabDragContext);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function acceptsWorkspaceTabDrop(
|
||||
allowCrossPane: boolean,
|
||||
sourcePaneId: string,
|
||||
destination: { kind: "workspace-tab" | "split-pane-drop"; paneId: string },
|
||||
): boolean {
|
||||
return (
|
||||
allowCrossPane || (destination.kind === "workspace-tab" && sourcePaneId === destination.paneId)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { WorkspaceTabDragScope as WebScope } from "./workspace-tab-drag-scope.web";
|
||||
export function WorkspaceTabDragScope({ children }: ComponentProps<typeof WebScope>) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { computeTabDropPreview, type TabDropPreview } from "./split-container-tab-drop-preview";
|
||||
import { resolveSplitDropPosition, type SplitDropZoneHover } from "./split-drop-zone";
|
||||
import { getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
collectAllPanes,
|
||||
createWorkspaceLayoutWithExplorerSidebar,
|
||||
type WorkspaceLayout,
|
||||
type SplitPane,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
|
||||
import { acceptsWorkspaceTabDrop } from "./workspace-tab-drag-policy";
|
||||
import { WorkspaceTabDragContext } from "./workspace-tab-drag-context";
|
||||
interface WorkspaceTabDragData {
|
||||
kind: "workspace-tab";
|
||||
paneId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneDropData {
|
||||
kind: "split-pane-drop";
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
|
||||
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
|
||||
return (
|
||||
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
|
||||
);
|
||||
}
|
||||
|
||||
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
|
||||
return isWorkspaceTabDragData(data) ? data : undefined;
|
||||
}
|
||||
|
||||
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
|
||||
if (isWorkspaceTabDragData(data)) return data;
|
||||
if (isSplitPaneDropData(data)) return data;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface DragMoveRects {
|
||||
translatedRect: { left: number; top: number; width: number; height: number };
|
||||
overRect: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
function resolveDragMoveRects(
|
||||
event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
|
||||
): DragMoveRects | null {
|
||||
const translatedRect = event.active.rect.current.translated;
|
||||
const overRect = event.over?.rect;
|
||||
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { translatedRect, overRect };
|
||||
}
|
||||
|
||||
function computeTabOverDropPreview(input: {
|
||||
activeData: WorkspaceTabDragData;
|
||||
overData: WorkspaceTabDragData;
|
||||
rects: DragMoveRects;
|
||||
panesById: Map<string, SplitPane>;
|
||||
uiTabs: WorkspaceTab[];
|
||||
}): TabDropPreview | null {
|
||||
const { activeData, overData, rects, panesById, uiTabs } = input;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!targetPane) {
|
||||
return null;
|
||||
}
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
return computeTabDropPreview({
|
||||
activePaneId: activeData.paneId,
|
||||
activeTabId: activeData.tabId,
|
||||
overPaneId: overData.paneId,
|
||||
overTabId: overData.tabId,
|
||||
targetTabs,
|
||||
activeRect: {
|
||||
left: rects.translatedRect.left,
|
||||
width: rects.translatedRect.width,
|
||||
},
|
||||
overRect: {
|
||||
left: rects.overRect.left,
|
||||
width: rects.overRect.width,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function computePaneOverDropPreview(input: {
|
||||
overData: SplitPaneDropData;
|
||||
rects: DragMoveRects;
|
||||
}): SplitDropZoneHover | null {
|
||||
const { overData, rects } = input;
|
||||
const centerX = rects.translatedRect.left + rects.translatedRect.width / 2;
|
||||
const centerY = rects.translatedRect.top + rects.translatedRect.height / 2;
|
||||
const relativeX = centerX - rects.overRect.left;
|
||||
const relativeY = centerY - rects.overRect.top;
|
||||
if (
|
||||
Number.isNaN(relativeX) ||
|
||||
Number.isNaN(relativeY) ||
|
||||
relativeX < 0 ||
|
||||
relativeX > rects.overRect.width ||
|
||||
relativeY < 0 ||
|
||||
relativeY > rects.overRect.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
paneId: overData.paneId,
|
||||
position: resolveSplitDropPosition({
|
||||
width: rects.overRect.width,
|
||||
height: rects.overRect.height,
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||
const pointerHits = pointerWithin(args);
|
||||
const tabHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
|
||||
);
|
||||
if (tabHits.length > 0) {
|
||||
return tabHits;
|
||||
}
|
||||
|
||||
const paneHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
|
||||
);
|
||||
if (paneHits.length > 0) {
|
||||
return paneHits;
|
||||
}
|
||||
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
const EMPTY_LAYOUT = createWorkspaceLayoutWithExplorerSidebar();
|
||||
|
||||
interface WorkspaceTabDragScopeProps {
|
||||
children: ReactNode;
|
||||
layout: WorkspaceLayout | null;
|
||||
explorerSidebarPaneId: string | null;
|
||||
allowCrossPane: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
onMoveTabToPane: (tabId: string, paneId: string) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
onSplitPane: (input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
}
|
||||
export function WorkspaceTabDragScope({
|
||||
children,
|
||||
layout,
|
||||
explorerSidebarPaneId,
|
||||
allowCrossPane,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
uiTabs,
|
||||
onMoveTabToPane,
|
||||
onReorderTabsInPane,
|
||||
onSplitPane,
|
||||
}: WorkspaceTabDragScopeProps) {
|
||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const panesById = useMemo(
|
||||
() => new Map(collectAllPanes((layout ?? EMPTY_LAYOUT).root).map((pane) => [pane.id, pane])),
|
||||
[layout],
|
||||
);
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
setActiveDragTabId(data.tabId);
|
||||
}, []);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
}, []);
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
if (
|
||||
!activeData ||
|
||||
!overData ||
|
||||
!acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData)
|
||||
) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rects = resolveDragMoveRects(event);
|
||||
if (!rects) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
|
||||
const destinationPaneId =
|
||||
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
|
||||
? overData.paneId
|
||||
: null;
|
||||
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
|
||||
if (
|
||||
!activeTab ||
|
||||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
|
||||
) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const preview = computeTabOverDropPreview({
|
||||
activeData,
|
||||
overData,
|
||||
rects,
|
||||
panesById,
|
||||
uiTabs,
|
||||
});
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(preview);
|
||||
return;
|
||||
}
|
||||
|
||||
setTabDropPreview(null);
|
||||
if (overData?.kind !== "split-pane-drop") {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDropPreview(computePaneOverDropPreview({ overData, rects }));
|
||||
},
|
||||
[allowCrossPane, normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
|
||||
);
|
||||
|
||||
const applyTabDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
const sourcePane = panesById.get(activeData.paneId) ?? null;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!sourcePane || !targetPane) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||
const resolvedTabDropPreview =
|
||||
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
|
||||
if (sourceIndex < 0 || !resolvedTabDropPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeData.paneId === overData.paneId) {
|
||||
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||
const nextTabs = arrayMove(
|
||||
sourceTabs,
|
||||
sourceIndex,
|
||||
resolvedTabDropPreview.insertionIndex,
|
||||
);
|
||||
onReorderTabsInPane(
|
||||
activeData.paneId,
|
||||
nextTabs.map((tab) => tab.tabId),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
|
||||
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
||||
},
|
||||
[onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs],
|
||||
);
|
||||
|
||||
const applyPaneDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
if (dropPreview?.paneId !== overData.paneId) {
|
||||
return;
|
||||
}
|
||||
if (dropPreview.position === "center") {
|
||||
if (activeData.paneId !== overData.paneId) {
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSplitPane({
|
||||
tabId: activeData.tabId,
|
||||
targetPaneId: overData.paneId,
|
||||
position: dropPreview.position,
|
||||
});
|
||||
},
|
||||
[dropPreview, onMoveTabToPane, onSplitPane],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
if (
|
||||
activeData &&
|
||||
overData &&
|
||||
event.over &&
|
||||
acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData)
|
||||
) {
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
applyTabDropEnd({ activeData, overData });
|
||||
} else if (overData?.kind === "split-pane-drop") {
|
||||
applyPaneDropEnd({ activeData, overData });
|
||||
}
|
||||
}
|
||||
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
},
|
||||
[allowCrossPane, applyTabDropEnd, applyPaneDropEnd],
|
||||
);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({ activeDragTabId, dropPreview, tabDropPreview, externalDndContext: true }),
|
||||
[activeDragTabId, dropPreview, tabDropPreview],
|
||||
);
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={dropCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={updateDropPreview}
|
||||
onDragOver={updateDropPreview}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<WorkspaceTabDragContext value={context}>{children}</WorkspaceTabDragContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
<DragOverlayTabChip
|
||||
tabId={activeDragTabId}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
function DragOverlayTabChip({
|
||||
tabId,
|
||||
uiTabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tabId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const tab = uiTabs.find((t) => t.tabId === tabId);
|
||||
const descriptor = useMemo<WorkspaceTabDescriptor | null>(
|
||||
() =>
|
||||
tab
|
||||
? {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
}
|
||||
: null,
|
||||
[tab],
|
||||
);
|
||||
if (!descriptor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DragOverlayTabChipInner
|
||||
tab={descriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChipInner({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const label =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
|
||||
return (
|
||||
<View style={styles.dragOverlayChip}>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
|
||||
<Text numberOfLines={1} style={styles.dragOverlayLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dragOverlayChip: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
maxWidth: 200,
|
||||
},
|
||||
dragOverlayLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
isActiveCreateFlowForDraft,
|
||||
useCreateFlowStore,
|
||||
useCreateFlowStoreApi,
|
||||
} from "@/stores/create-flow-store";
|
||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStoreApi } from "@/stores/session-store";
|
||||
import {
|
||||
createUserMessage,
|
||||
type StreamItem,
|
||||
@@ -122,6 +126,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onCreateError,
|
||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||
const { t } = useTranslation();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const flowStore = useCreateFlowStoreApi();
|
||||
const [localMachine, dispatch] = useReducer(
|
||||
reducer<TDraftAgent>,
|
||||
initialAttempt,
|
||||
@@ -235,6 +241,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
images: attempt.images,
|
||||
attachments: attempt.attachments,
|
||||
}),
|
||||
sessionStore,
|
||||
);
|
||||
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
|
||||
}
|
||||
@@ -255,6 +262,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
},
|
||||
[
|
||||
createRequest,
|
||||
sessionStore,
|
||||
draftId,
|
||||
getPendingServerId,
|
||||
markPendingCreateLifecycle,
|
||||
@@ -268,7 +276,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
const handleCreateFromInput = useCallback(
|
||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
|
||||
const existing = flowStore.getState().pendingByDraftId[draftId];
|
||||
if (
|
||||
isSubmitting ||
|
||||
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
|
||||
@@ -285,7 +293,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
throw error;
|
||||
}
|
||||
const supportsForgeSearch =
|
||||
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
||||
sessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
||||
true;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
@@ -342,6 +350,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
draftId,
|
||||
getPendingServerId,
|
||||
isSubmitting,
|
||||
flowStore,
|
||||
sessionStore,
|
||||
onCreateStart,
|
||||
runCreateAttempt,
|
||||
setPendingCreateAttempt,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user