Compare commits

..
Author SHA1 Message Date
Mohamed Boudra cdce65dfd5 test(app): stabilize archive descriptor assertions
The test rebuilt expected provider rows with fresh timestamps, so its result depended on both fixture calls landing in the same millisecond. Assert the preserved descriptor identities instead, matching the behavior under test without clock dependence.
2026-08-16 07:48:03 +00:00
Mohamed Boudra 7f8a485a77 Keep large diff reviews responsive (#3422)
* perf(app): bound large diff row mounting

Keep the existing vertical and horizontal scroll owners so virtualization does not change gesture or scrolling behavior. Reserve exact offscreen row heights while mounting only the visible window, and retain the full renderer when wrapping or editing reviews requires dynamic geometry.

* perf(app): keep large diff reviews responsive

Opening an inline editor changed the whole review action object, reconciled large syntax-token trees, and disabled row virtualization. Keep the expensive token subtree stable and preserve deterministic row geometry while the editor is open.\n\nBound server-side highlighting for pathological single lines and remove the unused client highlighter so the actual diff pipeline owns the limit.

* test(app): check mounted diff typography

Row virtualization can unmount a fixed diff row after scrolling. Assert the configured font size against a currently mounted row so the test continues to verify rendered typography without depending on window position.

* test(app): stabilize Mermaid streaming coverage
2026-08-16 15:38:12 +08:00
Mohamed Boudra 4748aad103 Reload daemon configuration without restarting (#3365)
* feat(daemon): reload configuration without restarting

Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.

* fix(daemon): preserve reload resource ownership

Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.
2026-08-14 22:46:32 +02:00
Mohamed Boudra 75bbca4a1b Archive finished subagents across the whole track (#3368)
* feat(app): archive finished subagents together

Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local.

* test(app): clarify subagent archive assertions
2026-08-14 18:16:22 +02:00
Mohamed Boudra 23853a1515 Add managed local plugin lifecycle (#3222)
* feat(plugins): add local plugin surfaces and RPCs

Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.

* feat(plugins): add Linear attachment source

* fix(nix): refresh npm dependency hash

* test(server): exclude internal sessions from Hub counts

* test(server): distinguish Hub execution sessions

* feat(plugins): make local plugins explicitly opt-in

Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.

* refactor(plugins): establish host boundaries

* fix(nix): refresh plugin dependency hash

* fix(plugins): complete packaging and startup rollback

* feat(plugins): add managed local lifecycle

Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.

* fix(nix): refresh plugin dependency hash
2026-08-14 17:12:49 +02:00
Mohamed Boudra f0bd2c8483 Resume cached timelines without replaying their tail (#3329)
* perf(timeline): resume cached history from its exact range

Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback.

* test(timeline): hide replica setup behind E2E helper

* fix(timeline): bound persisted resume overflow

* test(timeline): cover workspace eviction catch-up order

* test(timeline): read persisted range from IndexedDB
2026-08-14 12:57:44 +02:00
Mohamed BoudraandJimmy Lee 30195d3bd1 fix(app): make composer input IME-safe and paste-aware (#3343)
* fix(app): preserve native composer input semantics

Native text was round-tripped through application state on every keystroke, which could overwrite an input method's in-flight edits. Keep native text ownership inside the input and reserve application-driven replacement for explicit draft mutations.\n\nRoute native clipboard images through the existing persisted attachment pipeline so keyboard and system paste actions behave like other image sources.

* fix(nix): refresh dependency hash

The native composer dependency changes the fixed npm dependency closure used by desktop builds.

* fix(app): keep composer locked across image pastes

Count concurrent native image persistence operations so submit remains disabled until every pasted image is attached.

* fix(app): replace composer text through the DOM on web

Web TextInput refs are DOM elements, so application-driven draft replacement must update their value and selection without native setNativeProps.

* fix(app): preserve web IME composition

Carry the Chinese IME fix from #2990 into the web composer input boundary.

Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>

---------

Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
2026-08-14 11:52:24 +02:00
770b87a15c fix(desktop): keep element selectors available on loaded pages (#3187)
* fix(desktop): keep element selectors available on loaded pages

Annotate element and Screenshot element returned early when a cached load signal said the guest was not ready, even if the current document was complete and executable. Let executeJavaScript own the readiness boundary and keep selector cleanup available in the same stale-signal state.

Extend the real Electron browser regression to reproduce the complete-document/stale-signal mismatch through both toolbar actions.

* test(desktop): await browser selector injection

Poll the guest selector state through the browser bridge instead of assuming injection completes within 250 ms. This keeps the Electron regression deterministic on slower CI hosts.

* Harden browser selector session lifecycle

* Keep selector E2E fixtures independent

* fix(desktop): harden selector and annotation lifecycle

* fix(desktop): keep selector PR focused

* refactor(desktop): own element selector lifecycle

---------

Co-authored-by: Daniel G. Kang <180234654+dgk-dev@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-14 15:51:42 +08:00
Mohamed Boudra b03dc804eb feat(app): make pinned workspaces sortable (#3341)
Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web.
2026-08-13 23:48:01 +02:00
Mohamed Boudra 373a98c64d Restore host directories instantly before reconnect (#3259)
* feat(sync): restore host directories before reconnect

Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.

* fix(sync): preserve active turn identity during catch-up

Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.

* test(e2e): inspect the real IndexedDB replica

Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.

* fix warm reconnect cache paths

* feat(sync): persist project icons and deepen directory sync

* fix(sync): preserve project update order

* fix(cache): preserve validated replica persistence

* fix(sync): retain cached agents during catch-up
2026-08-13 23:10:44 +02:00
Mohamed Boudra 599846650c Stabilize live relay status coverage (#3337)
* test(cli): stabilize live relay status coverage

The regression fixture deleted the supervisor's active PID lock to model a reachable daemon without local process state. Under CI load, worker readiness could race that deletion and make the supervisor tear down the daemon while status and pairing were probing it.\n\nLaunch the real worker directly so the no-PID state exists naturally, then wait on the CLI-visible live relay state before asserting behavior.

* test(cli): wait for complete relay probe state

Status readiness alone does not guarantee the next pairing RPC or the foreign-home identity probe will finish inside the CLI's per-call timeout under CI contention. Retry each exact observable outcome within the fixture deadline and resolve the real worker entry through the server package.

* test(cli): name retry mechanics once

Keep the relay scenarios expressed as CLI probes and exact outcomes while one file-local helper owns the shared deadline, worker-liveness check, and retry cadence.
2026-08-13 23:10:28 +02:00
284 changed files with 17411 additions and 3334 deletions
+1
View File
@@ -18,6 +18,7 @@ on:
- "packages/client/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
+1
View File
@@ -10,6 +10,7 @@ on:
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
+1
View File
@@ -12,6 +12,7 @@ on:
- "packages/app/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
+1
View File
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
+1
View File
@@ -23,6 +23,7 @@ RUN set -eux; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
+2 -2
View File
@@ -165,7 +165,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
@@ -185,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
### Cross-client tab dismissal
+16 -7
View File
@@ -69,6 +69,7 @@ not retain non-Git directories.
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
@@ -97,19 +98,27 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
The replica cache exists only to paint stale data immediately while the host connects. It does not
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
written order; a single oversized host is omitted rather than partially restored.
The replica cache paints stale data immediately while the host connects. Directory cursors are
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
host snapshots in least-recently-written order; a single oversized host is omitted rather than
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
AsyncStorage, and Android reserves 64 MiB for that database.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
independent records; a project with no workspaces does not need a workspace placeholder.
### `packages/cli` — Command-line client
+20 -34
View File
@@ -30,20 +30,7 @@ Workspace archive runs lifecycle teardown from the exact `cwd` but removes only
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
Paseo stores schedules and push tokens in `$PASEO_HOME/paseo.db`. The remaining record stores use
JSON files. SQLite migrations are ordered SQL scripts tracked by `PRAGMA user_version`. Record
tables keep identity and queried fields in columns and the complete record in a JSON `payload`
column. Reads validate that payload with the same Zod schema used at the store boundary.
Each SQLite-backed store owns a one-way import from its legacy JSON path. On first open, an empty
table imports valid legacy records in one transaction and writes a marker to `legacy_imports`.
Invalid records are skipped and logged. Once the marker exists, SQLite is the only authority for
that store: the daemon does not read from or write to the legacy path. Legacy files remain untouched
so downgrading resumes from the state captured when the import ran; there is no automatic
down-migration.
JSON stores rely on optional fields with defaults for forward compatibility. Most write atomically
with a temporary file and rename; see each section for exceptions.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
@@ -62,12 +49,11 @@ $PASEO_HOME/
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
├── paseo.pid # Daemon PID lock file
├── daemon.log # Default log file (path configurable)
├── paseo.db # SQLite schedules, push tokens, and import markers
├── agents/
│ └── {sanitized-cwd}/
│ └── {agentId}.json # One file per agent
├── schedules/ # Legacy schedule import source, left untouched
│ └── {scheduleId}.json
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── projects/
│ ├── projects.json # Project registry
│ ├── workspaces.json # Workspace registry
@@ -75,10 +61,10 @@ $PASEO_HOME/
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
└── push-tokens.json # Legacy push token import source, left untouched
└── push-tokens.json # Expo push notification tokens
```
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). File-backed record stores write atomically by writing a temp file in the target directory and then renaming it into place.
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
---
@@ -189,6 +175,13 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
Single file, validated with `PersistedConfigSchema`.
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
fields and their removal/default semantics; session handlers and the CLI only relay the structured
result. Normal config patches persist only the requested fields, so launch overrides and resolved
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
```
{
version: 1,
@@ -292,9 +285,9 @@ Environment variables override `config.json`:
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
@@ -339,11 +332,9 @@ Paseo uses these paths under the configured OpenAI base URL:
## 3. Schedule
**Table:** `paseo.db:schedules`
**Path:** `$PASEO_HOME/schedules/{id}.json`
Each row stores `id`, `created_at`, the active name-and-target `identity_key`, and the validated
schedule JSON in `payload`. ID is 8 hex characters. The legacy
`$PASEO_HOME/schedules/{id}.json` files are import sources only.
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
@@ -458,20 +449,15 @@ than treating it as valid.
## 6. Push Token Store
**Table:** `paseo.db:push_tokens`
Legacy import source:
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"subscriptions": [{ "token": "ExponentPushToken[...]", "expiresAt": "2026-08-12T00:00:00.000Z" }]
"tokens": ["ExponentPushToken[...]", ...]
}
```
The older `{ "tokens": [...] }` shape is imported with a fresh lease. Each token is a row keyed by
its normalized string, with the validated subscription record in `payload`.
`$PASEO_HOME/push-tokens.json` is read once as an import source. Non-string and empty legacy entries
are skipped and logged.
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
---
+2 -2
View File
@@ -266,8 +266,8 @@ the daemon-global Git process limits in `$PASEO_HOME/config.json`:
}
```
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and
Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
semantics, and environment-variable overrides.
+173
View File
@@ -0,0 +1,173 @@
# Local plugins
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
## Install a directory source
Create a typecheckable plugin project, install its development dependencies, then install it into
the daemon. `init` only writes the project files; it does not run the package manager.
```bash
paseo plugin init /absolute/path/to/my-plugin
cd /absolute/path/to/my-plugin
npm install
npm run typecheck
paseo plugin install /absolute/path/to/my-plugin
paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id
paseo plugin ls
```
The daemon stores directory sources under the root `plugins` object:
```json
{
"pluginsEnabled": true,
"plugins": {
"my-plugin": {
"source": "directory",
"path": "/absolute/path/to/my-plugin",
"enabled": true
}
}
}
```
The plugin system is disabled unless `pluginsEnabled` is `true`.
The directory contains an identity-only manifest, one entry point, and local typechecking support:
```text
my-plugin/
paseo-plugin.json
index.tsx
paseo-plugin.d.ts
package.json
tsconfig.json
```
Paseo compiles TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
```json
{
"id": "my-plugin"
}
```
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
runtime does not compare the two IDs. The same directory can be installed under several config
keys.
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
## Contribute behavior and UI
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
removes UI registrations from the server bundle and RPC registrations from the client bundle before
resolving dependencies.
```tsx
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
function Greeting() {
const greet = useRpc(greetRpc);
const query = useQuery({
queryKey: ["greeting", "Paseo"],
queryFn: () => greet({ name: "Paseo" }),
});
return <Text>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({
id: "main",
title: "Greeting",
icon: "MessageCircle",
surface: "main",
});
return () => undefined;
}
```
The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded,
disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code.
Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs,
and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
credentials and vendor API calls stay in the daemon handler.
```tsx
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
}
```
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
attachment-source example.
+15 -9
View File
@@ -85,16 +85,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
The durable replica cache persists synchronization authority only when it can store the complete
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
`after endSeq`, and requests `before startSeq` when the user loads older history.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
the authoritative range through the existing forward-page path. The cache does not persist sync
generation or unreconciled local submissions.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
+1 -1
View File
@@ -1 +1 @@
sha256-BeS2/PYAPoG4U3q7N2qOVuPC1T5sDQ+c9hv2cTn3KRU=
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
+540 -17
View File
@@ -12,6 +12,7 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
"packages/protocol",
"packages/client",
"packages/server",
@@ -7762,6 +7763,16 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mattermost/react-native-paste-input": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-2.0.1.tgz",
"integrity": "sha512-Q9zjXvjwxVQ1M8ylxr9Y+wUpG7xb1wW4u+I34oPvF3etTyBrGtVyA4Bt7Xphgc29eW6wQFUxTIbzZc3E6W2MQg==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": ">=0.76.0"
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
@@ -9558,6 +9569,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@paseo/plugin": {
"resolved": "packages/plugin",
"link": true
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -36108,6 +36123,8 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@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",
@@ -37163,9 +37180,6 @@
"typescript": "^5.2.2",
"vitest": "^4.1.6",
"zx": "^8.8.5"
},
"engines": {
"node": ">=22.20.0"
}
},
"packages/cli/node_modules/@vitest/expect": {
@@ -38574,6 +38588,22 @@
"vitest": "^4.1.6"
}
},
"packages/plugin": {
"name": "@paseo/plugin",
"version": "0.3.0",
"devDependencies": {
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6",
"zod": "^4.4.3"
},
"peerDependencies": {
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.4.0",
@@ -38812,6 +38842,7 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.4.0",
"@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0",
@@ -38819,11 +38850,13 @@
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
@@ -38848,7 +38881,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/node": "^22.20.1",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
@@ -38857,9 +38890,6 @@
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
},
"engines": {
"node": ">=22.20.0"
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
@@ -38990,6 +39020,468 @@
"win32"
]
},
"packages/server/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"packages/server/node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
@@ -39008,16 +39500,6 @@
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/server/node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
@@ -39154,6 +39636,47 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"packages/server/node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+7 -4
View File
@@ -23,6 +23,7 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
"packages/protocol",
"packages/client",
"packages/server",
@@ -46,19 +47,21 @@
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@paseo/plugin",
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
+3 -1
View File
@@ -4,6 +4,7 @@ const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
@@ -139,7 +140,8 @@ export default {
},
plugins: [
"expo-router",
[withAndroidAsyncStorageSize, 10],
withPasteInput,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
[
"expo-splash-screen",
@@ -30,6 +30,12 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import {
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
import {
scrollTimelineToNewestLoadedEdge,
scrollTimelineUntilOlderHistoryIsReachable,
@@ -336,6 +342,64 @@ async function expectInterruptedTurnOrderAfterReconnect(
}
}
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const subscriptions = observeTimelineSubscriptions(page);
const gate = await installDaemonWebSocketGate(page);
const target = await seedMockAgentWorkspace({
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
title: "Hidden streaming submission",
model: "ten-second-stream",
});
const evictionAgents = await Promise.all(
Array.from({ length: 3 }, (_unused, index) =>
seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}`,
}),
),
);
const prompt = "Keep this hidden image prompt before its streaming output.";
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
try {
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.setAgentStreamSuppressed(true);
const promptRow = await submitMessageWithImage(page, prompt);
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
for (const evictionAgent of evictionAgents) {
await openAgentRoute(page, evictionAgent);
await expectComposerVisible(page);
}
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]);
gate.setAgentStreamSuppressed(false);
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
} finally {
gate.setAgentStreamSuppressed(false);
gate.restore();
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
}
}
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
page: Page,
testInfo: { workerIndex: number },
@@ -1013,6 +1077,13 @@ test.describe("Agent message submission", () => {
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
});
test("keeps a streaming hidden submission before its output after workspace eviction", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo);
});
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
page,
}, testInfo) => {
@@ -26,9 +26,41 @@ import {
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
waitForPersistedCanonicalTimelineRange,
} from "../support/helpers/timeline-pagination";
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Agent timeline pagination", () => {
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
const prompt = "timeline persisted range resumes without tail replay";
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-persisted-range-",
title: "Persisted timeline range",
initialPrompt: prompt,
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, prompt);
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
await page.reload();
await expectTimelinePromptVisible(page, prompt);
const request = await tracker.nextRequest();
expect(request).toEqual({
direction: "after",
cursor: { epoch: range.epoch, seq: range.endSeq },
});
await tracker.waitForResponse();
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
} finally {
await agent.cleanup();
}
});
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
@@ -13,9 +13,10 @@ import {
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectOneTailCheckWithoutCatchUpPages,
expectOneResumeCheckWithoutTail,
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
restoreViewedTimelineWithHeldTail,
restoreViewedTimelineWithHeldResponse,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
@@ -29,11 +30,11 @@ test.describe("Agent timeline resume", () => {
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
await restoreViewedTimelineWithHeldTail(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
await expectTimelinePromptNotMounted(page, background.firstPrompt);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
expectResumeOverflowFallsBackToOneTail(gate, requests);
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
} finally {
gate.restore();
@@ -53,10 +54,10 @@ test.describe("Agent timeline resume", () => {
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldTail(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
expectOneResumeCheckWithoutTail(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
@@ -0,0 +1,79 @@
import { test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
archiveFinishedSubagents,
expectArchiveFinishedInProgress,
expectArchiveFinishedRetry,
expectManagedSubagentArchived,
expectManagedSubagentUnarchived,
expectSubagentRowGone,
expectSubagentRowVisible,
holdManagedSubagentArchiveRequest,
openSubagentsTrack,
rejectNextManagedSubagentArchiveRequest,
seedParentWithSubagent,
} from "../support/helpers/subagents";
test.describe("Archive finished subagents", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("archives a finished managed child from the track", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Archive parent",
childTitle: "Finished child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await archiveGate.waitForRequest();
await expectSubagentRowGone(page, agents.child.id);
await expectArchiveFinishedInProgress(page, 0, 1);
await expectManagedSubagentUnarchived(workspace, agents.child.id);
archiveGate.release();
await expectManagedSubagentArchived(workspace, agents.child.id);
});
test("restores a failed child and retries the archive", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Retry parent",
childTitle: "Retry child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await rejection.waitForRejection();
await expectSubagentRowVisible(page, agents.child.id);
await expectArchiveFinishedRetry(page, 1, 1);
await archiveFinishedSubagents(page);
await expectSubagentRowGone(page, agents.child.id);
await expectManagedSubagentArchived(workspace, agents.child.id);
});
});
@@ -669,11 +669,13 @@ async function expectFlatFileList(page: Page): Promise<void> {
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const visibleCodeText = page
.getByTestId(/^diff-code-text-/)
.filter({ visible: true })
.first();
await expect
.poll(async () => {
return page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
return visibleCodeText.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
}
@@ -0,0 +1,184 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
const LINE_COUNT = 2_400;
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
const MAX_MOUNTED_CODE_ROWS = 220;
const MAX_EXPANSION_MS = 6_000;
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
diffPerfDescribe("Diff rendering performance", () => {
test("large unwrapped diffs keep mounting bounded under CPU slowdown", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
const repo = await createTempGitRepo("diff-perf-", {
files: [{ path: "src/large.ts", content: "export const baseline = 0;\n" }],
});
const client = await connectSeedClient();
let cdp: CDPSession | null = null;
try {
const generatedLines = Array.from(
{ length: LINE_COUNT },
(_, index) => `export const generated_${index} = "${index}-${"wide".repeat(16)}";`,
).join("\n");
await writeFile(
path.join(repo.path, "src/large.ts"),
`export const baseline = 1;\n${generatedLines}\n`,
);
const created = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create diff performance workspace");
}
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
const fileHeader = page.getByTestId("diff-file-0");
await expect(fileHeader).toContainText("large.ts", { timeout: 30_000 });
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
const expansionStart = await page.evaluate(() => performance.now());
await fileHeader.click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
const expansionMs = (await page.evaluate(() => performance.now())) - expansionStart;
const scroll = page.getByTestId("git-diff-scroll");
const initial = await readDiffMountReport(page);
expect(initial.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(initial.mountedGutterRows).toBe(initial.mountedCodeRows);
await page.getByTestId("diff-gutter-row-1").hover();
await page.getByTestId("diff-gutter-action-1").click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
const withEditor = await readDiffMountReport(page);
expect(withEditor.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(withEditor.mountedGutterRows).toBe(withEditor.mountedCodeRows);
expect(await readDiffRowAlignment(page, 2)).toBeCloseTo(0, 0);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await page.getByTestId("inline-review-editor-cancel").click();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await scroll.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => (await readDiffMountReport(page)).highestMountedRow)
.toBeGreaterThan(LINE_COUNT - 100);
const afterScroll = await readDiffMountReport(page);
expect(afterScroll.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(afterScroll.mountedGutterRows).toBe(afterScroll.mountedCodeRows);
const report = {
cpuSlowdown: CPU_SLOWDOWN,
sourceLines: LINE_COUNT,
expansionMs: Math.round(expansionMs),
initial,
withEditor,
afterScroll,
};
await testInfo.attach("diff-performance", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(`[perf] Diff rendering: ${JSON.stringify(report)}`);
expect(expansionMs).toBeLessThan(MAX_EXPANSION_MS);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
await scroll.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await page.getByTestId("changes-toggle-layout").click();
const oldLine = page.getByText("export const baseline = 0;", { exact: true });
const newLine = page.getByText("export const baseline = 1;", { exact: true });
await expect(oldLine).toBeVisible();
await expect(newLine).toBeVisible();
const [bodyBox, oldBox, newBox] = await Promise.all([
page.getByTestId("diff-file-0-body").boundingBox(),
oldLine.boundingBox(),
newLine.boundingBox(),
]);
expect(bodyBox).not.toBeNull();
expect(oldBox).not.toBeNull();
expect(newBox).not.toBeNull();
expect(oldBox!.x).toBeLessThan(newBox!.x);
expect(oldBox!.x).toBeGreaterThanOrEqual(bodyBox!.x);
expect(newBox!.x).toBeGreaterThan(bodyBox!.x + bodyBox!.width / 2);
expect(newBox!.x).toBeLessThan(bodyBox!.x + bodyBox!.width);
} finally {
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
await cdp?.detach().catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
});
async function readDiffRowAlignment(
page: import("@playwright/test").Page,
rowIndex: number,
): Promise<number> {
const gutterTop = await page
.getByTestId(`diff-gutter-row-${rowIndex}`)
.evaluate((row) => row.getBoundingClientRect().top);
const codeTop = await page
.getByTestId(`diff-code-row-${rowIndex}`)
.evaluate((row) => row.getBoundingClientRect().top);
return gutterTop - codeTop;
}
async function readDiffMountReport(page: import("@playwright/test").Page): Promise<{
mountedCodeRows: number;
mountedGutterRows: number;
highestMountedRow: number;
bodyDomElements: number;
}> {
return page.getByTestId("diff-file-0-body").evaluate((body) => {
const codeRows = Array.from(
body.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'),
);
const gutterRows = body.querySelectorAll('[data-testid^="diff-gutter-row-"]');
return {
mountedCodeRows: codeRows.length,
mountedGutterRows: gutterRows.length,
highestMountedRow: Math.max(
-1,
...codeRows.map((row) =>
Number((row.getAttribute("data-testid") ?? "").slice("diff-code-row-".length)),
),
),
bodyDomElements: body.querySelectorAll("*").length,
};
});
}
@@ -34,6 +34,8 @@ const STREAMED_MERMAID = [
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
page,
}) => {
test.setTimeout(90_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
title: "Streaming Mermaid",
@@ -0,0 +1,209 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectSettingsHeader,
openHostSection,
openSettingsHost,
} from "../support/helpers/settings";
function observePluginCatalog(page: Page) {
let responses = 0;
page.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
if (typeof payload !== "string") return;
try {
const envelope = JSON.parse(payload) as {
type?: unknown;
message?: { type?: unknown };
};
const message = envelope.type === "session" ? envelope.message : envelope;
if (message?.type === "plugin.catalog.get.response") responses += 1;
} catch {
return;
}
});
});
return {
waitForInitialFetch: async () => {
await expect.poll(() => responses).toBeGreaterThan(0);
},
};
}
function pluginSource(title: string, renderError?: string): string {
return `import React from "react";
import { Text } from "react-native";
export default function contribute(plugin) {
function Surface() {
${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""}
const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0");
return <Text>${title} cleanup {cleanups}</Text>;
}
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
${
renderError
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
plugin.addSurface("healthy", HealthySurface);
plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });`
: ""
}
return () => {
const storage = globalThis.sessionStorage;
if (storage) {
const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0");
storage.setItem("paseo-plugin-cleanups", String(cleanups + 1));
}
};
}`;
}
async function openPluginSettings(page: Page): Promise<void> {
const serverId = getServerId();
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "plugins");
await expectSettingsHeader(page, "Plugins");
}
async function reloadPlugin(page: Page): Promise<void> {
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
}
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
await expect(body).toHaveCount(1);
await expect(body).toBeVisible();
}
async function openContribution(page: Page, title: string, expectedBody: string): Promise<void> {
await page.getByRole("button", { name: title, exact: true }).click();
await expectContributionBody(page, expectedBody);
}
async function leavePluginSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).not.toHaveURL(/\/settings\//);
}
async function openContributionFromSettings(
page: Page,
title: string,
expectedBody: string,
): Promise<void> {
await leavePluginSettings(page);
await openContribution(page, title, expectedBody);
}
async function reloadActiveContribution(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
expectedBody: string,
): Promise<void> {
await client.reloadPlugin("e2e-plugin");
await expectContributionBody(page, expectedBody);
}
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
}
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
page,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
try {
const catalog = observePluginCatalog(page);
await gotoAppShell(page);
await openPluginSettings(page);
await catalog.waitForInitialFetch();
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
await page.getByLabel("Plugin directory").fill(directory);
await page.getByLabel("Plugin installation ID").focus();
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await writeFile(
path.join(directory, "index.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(
page.getByRole("button", { name: "Broken surface", exact: true }),
).not.toBeVisible();
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
await openPluginSettings(page);
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Plugin v3");
await openPluginSettings(page);
await page.getByRole("button", { name: "Enable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
await openPluginSettings(page);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Remove", exact: true }).click();
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
await page.getByLabel("Plugin directory").fill(directory);
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await gotoAppShell(page);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
} finally {
await client.removePlugin("e2e-plugin").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -10,6 +10,7 @@ import {
fillComposerDraft,
submitMessage,
} from "../support/helpers/composer";
import { readReplicaCache, writeReplicaCache } from "../support/helpers/replica-cache-storage";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
@@ -27,39 +28,28 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
return true;
}
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === prompt && item.messageId) {
return true;
}
}
return false;
}, prompt),
)
}
return false;
})
.toBe(true);
await page.evaluate((messageText) => {
const key = "@paseo:replica-cache";
const raw = localStorage.getItem(key);
if (!raw) throw new Error("Replica cache was not persisted");
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === messageText);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
localStorage.setItem(key, JSON.stringify(cache));
}, prompt);
const cache = await readReplicaCache(page);
if (!cache) throw new Error("Replica cache was not persisted");
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === prompt);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
await writeReplicaCache(page, cache);
}
async function waitForCurrentSubmissionExcludedFromCache(
@@ -67,46 +57,34 @@ async function waitForCurrentSubmissionExcludedFromCache(
prompt: string,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
}, prompt),
)
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === prompt &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
})
.toBe(true);
}
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
item.messageId === undefined,
);
}, prompt),
)
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
);
})
.toBe(true);
}
@@ -148,16 +126,13 @@ test.describe("Rewind sheet", () => {
title: "Rewind cache upgrade e2e",
initialPrompt: prompt,
});
let heldTimelineRequest = false;
try {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await gate.drop();
await rewriteCachedMessageAsLegacyRow(page, prompt);
gate.holdNextClientRequest("fetch_agent_timeline_request");
await page.reload();
await gate.waitForHeldClientRequest();
heldTimelineRequest = true;
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
@@ -165,7 +140,6 @@ test.describe("Rewind sheet", () => {
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt);
} finally {
if (heldTimelineRequest) gate.releaseHeldClientRequest();
gate.restore();
await session.cleanup();
}
@@ -18,7 +18,25 @@ async function visibleBoundingBox(row: Locator) {
return box;
}
async function quickDragFirstRowAfterSecond(rows: Locator) {
async function pressProjectRow(rows: Locator) {
await rows.page().mouse.down();
}
async function pressWorkspaceRow(rows: Locator) {
const solidScrimStop = rows
.nth(0)
.getByTestId("sidebar-workspace-trailing-scrim")
.locator("stop")
.nth(1);
const hoverScrimColor = await solidScrimStop.getAttribute("stop-color");
await rows.page().mouse.down();
await expect.poll(() => solidScrimStop.getAttribute("stop-color")).not.toBe(hoverScrimColor);
}
async function quickDragFirstRowAfterSecond(
rows: Locator,
pressRow: (rows: Locator) => Promise<void>,
) {
await expect(rows).toHaveCount(2);
const before = await rowTestIds(rows);
const sourceBox = await visibleBoundingBox(rows.nth(0));
@@ -29,15 +47,19 @@ async function quickDragFirstRowAfterSecond(rows: Locator) {
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
await page.mouse.move(source.x, source.y);
await page.mouse.down();
const trailingScrim = rows.nth(0).getByTestId("sidebar-workspace-trailing-scrim");
await pressRow(rows);
await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 });
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
page,
}) => {
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
@@ -61,6 +83,7 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`;
await quickDragFirstRowAfterSecond(
page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`),
pressProjectRow,
);
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
@@ -68,6 +91,17 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
),
pressWorkspaceRow,
);
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
const secondProjectWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondProject.workspaceId}`;
await quickDragFirstRowAfterSecond(
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondProjectWorkspaceTestId}"]`,
),
pressWorkspaceRow,
);
} finally {
await firstProject.cleanup();
@@ -31,6 +31,82 @@ export interface PromptJumpRequestTracker {
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
}
export interface TimelineRequestTracker {
nextRequest(): Promise<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
requests(): Array<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
waitForResponse(): Promise<void>;
}
export async function trackAgentTimelineRequests(
page: Page,
agentId: string,
): Promise<TimelineRequestTracker> {
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
const seen: Request[] = [];
const waiters: Array<(request: Request) => void> = [];
let responseSeen = false;
let resolveResponse: (() => void) | null = null;
const response = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId
) {
const rawCursor = sessionMessage.cursor;
const cursor =
rawCursor &&
typeof rawCursor === "object" &&
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
typeof (rawCursor as { seq?: unknown }).seq === "number"
? {
epoch: (rawCursor as { epoch: string }).epoch,
seq: (rawCursor as { seq: number }).seq,
}
: null;
const request = {
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
cursor,
};
seen.push(request);
waiters.shift()?.(request);
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId
) {
responseSeen = true;
resolveResponse?.();
}
ws.send(message);
});
});
return {
nextRequest() {
const request = seen[0];
if (request) return Promise.resolve(request);
return new Promise((resolve) => waiters.push(resolve));
},
requests: () => [...seen],
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
};
}
export async function trackPromptJumpRequests(
page: Page,
agentId: string,
+2 -2
View File
@@ -30,7 +30,7 @@ export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Pr
const samples = 80;
for (let sample = 0; sample < samples; sample += 1) {
await expect(diagram).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 500 });
await page.waitForTimeout(25);
}
}
@@ -44,5 +44,5 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
}
export async function reloadConversation(page: Page): Promise<void> {
await page.reload();
await page.reload({ waitUntil: "domcontentloaded" });
}
@@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick<
| "on"
| "patchDaemonConfig"
| "removeProject"
| "removePlugin"
| "reloadPlugin"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -1,6 +1,9 @@
import type { Page } from "@playwright/test";
const REPLICA_CACHE_STORAGE_KEY = "@paseo:replica-cache";
import {
observeReplicaCacheStorageWrites,
readReplicaCacheStorageWriteObserver,
resetReplicaCacheStorageWriteObserver,
} from "./replica-cache-storage";
export interface ReplicaCachePerfReport {
elapsedMs: number;
@@ -11,12 +14,7 @@ export interface ReplicaCachePerfReport {
}
interface ReplicaCachePerfState {
lastValue: string | null;
resetAt: number;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
declare global {
@@ -26,55 +24,25 @@ declare global {
}
export async function observeReplicaCacheWrites(page: Page): Promise<void> {
await page.addInitScript((storageKey) => {
const originalSetItem = Storage.prototype.setItem;
window.__replicaCachePerf = {
lastValue: localStorage.getItem(storageKey),
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
Storage.prototype.setItem = function measuredSetItem(key: string, value: string): void {
const startedAt = performance.now();
originalSetItem.call(this, key, value);
if (key !== storageKey) return;
const state = window.__replicaCachePerf;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
state.writes += 1;
state.serializedChars += value.length;
state.storageDurationMs += performance.now() - startedAt;
};
}, REPLICA_CACHE_STORAGE_KEY);
await observeReplicaCacheStorageWrites(page);
await page.addInitScript(() => {
window.__replicaCachePerf = { resetAt: performance.now() };
});
}
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
await resetReplicaCacheStorageWriteObserver(page);
await page.evaluate(() => {
const lastValue = window.__replicaCachePerf?.lastValue ?? null;
window.__replicaCachePerf = {
lastValue,
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
window.__replicaCachePerf = { resetAt: performance.now() };
});
}
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
return page.evaluate(() => {
const storage = await readReplicaCacheStorageWriteObserver(page);
const elapsedMs = await page.evaluate(() => {
const state = window.__replicaCachePerf;
if (!state) throw new Error("Replica cache performance observer is not installed");
return {
elapsedMs: performance.now() - state.resetAt,
redundantWrites: state.redundantWrites,
serializedChars: state.serializedChars,
storageDurationMs: state.storageDurationMs,
writes: state.writes,
};
return performance.now() - state.resetAt;
});
return { elapsedMs, ...storage };
}
@@ -0,0 +1,156 @@
import type { Page } from "@playwright/test";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
const STORAGE_KEY = "@paseo:replica-cache";
export interface ReplicaCacheRecord {
version?: number;
hosts?: Array<{
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
endSeq?: number;
epoch?: string;
startSeq?: number;
} | null;
} | null;
}>;
}
interface ReplicaCacheWriteObserverState {
lastValue: string | null;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
declare global {
interface Window {
__replicaCacheWriteObserver?: ReplicaCacheWriteObserverState;
}
}
async function readStoredValue(input: {
databaseName: string;
storeName: string;
storageKey: string;
}): Promise<string | null> {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(input.databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
return new Promise((resolve, reject) => {
const request = database
.transaction(input.storeName, "readonly")
.objectStore(input.storeName)
.get(input.storageKey);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
request.addEventListener("error", () => reject(request.error));
});
}
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
const raw = await page.evaluate(readStoredValue, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
}
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
await page.evaluate(
async ({ databaseName, storeName, storageKey, raw }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction(storeName, "readwrite");
transaction.objectStore(storeName).put(raw, storageKey);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () => reject(transaction.error));
transaction.addEventListener("abort", () => reject(transaction.error));
});
},
{
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
raw: JSON.stringify(value),
},
);
}
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName, storeName, storageKey }) => {
window.__replicaCacheWriteObserver = {
lastValue: null,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
const request =
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
if (
this.transaction.db.name !== databaseName ||
this.name !== storeName ||
key !== storageKey ||
typeof value !== "string"
) {
return request;
}
const startedAt = performance.now();
request.addEventListener("success", () => {
const state = window.__replicaCacheWriteObserver;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
state.writes += 1;
state.serializedChars += value.length;
state.storageDurationMs += performance.now() - startedAt;
});
return request;
};
},
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
);
}
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
const lastValue = await page.evaluate(readStoredValue, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
await page.evaluate((value) => {
window.__replicaCacheWriteObserver = {
lastValue: value,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
}, lastValue);
}
export async function readReplicaCacheStorageWriteObserver(
page: Page,
): Promise<ReplicaCacheWriteObserverState> {
return page.evaluate(() => {
const state = window.__replicaCacheWriteObserver;
if (!state) throw new Error("Replica cache write observer is not installed");
return state;
});
}
@@ -42,6 +42,7 @@ export interface SeedDaemonClient {
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
createWorkspace(input: {
source:
@@ -41,6 +41,7 @@ type HostSection =
| "providers"
| "usage"
| "terminals"
| "plugins"
| "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
@@ -1,7 +1,19 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import type { SeededWorkspace } from "./seed-client";
type WebSocketMessage = string | Buffer;
export interface ManagedSubagentArchiveGate {
release(): void;
waitForRequest(): Promise<void>;
}
export interface ManagedSubagentArchiveRejection {
waitForRejection(): Promise<void>;
}
export interface SeededSubagentPair {
parent: {
id: string;
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
});
}
export async function expectManagedSubagentUnarchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
agent: { id: childId, archivedAt: null },
});
}
export async function expectManagedSubagentArchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
page: Page,
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
export async function expectArchiveFinishedRetry(
page: Page,
failed: number,
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveGate> {
let released = false;
let resolveRequest: (() => void) | undefined;
const delayedForwards: Array<() => void> = [];
const request = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
delayedForwards.push(() => server.send(message));
resolveRequest?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) forward();
},
waitForRequest: () => request,
};
}
export async function rejectNextManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveRejection> {
let resolveRejection: (() => void) | undefined;
const rejection = new Promise<void>((resolve) => {
resolveRejection = resolve;
});
let rejected = false;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const requestId = archiveRequestId(message, subagentId);
if (requestId && !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",
},
},
}),
);
resolveRejection?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRejection: () => rejection };
}
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
return archiveRequestId(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;
}
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
const row = page.getByTestId(`subagents-track-row-${childId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse,
@@ -39,6 +40,12 @@ interface TimelineViewportSnapshot {
scrollTop: number;
}
interface PersistedCanonicalTimelineRange {
epoch: string;
startSeq: number;
endSeq: number;
}
export interface TimelinePresentationSnapshot {
marker: string;
position: TimelinePromptPositionSnapshot;
@@ -284,28 +291,45 @@ export async function reloadAgentTimelineFromPersistedReplica(
agent: LongTimelineAgent,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((agentId) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{
timeline?: {
agentId?: string;
items?: unknown[];
} | null;
}>;
};
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
return timeline?.items?.length === 50;
}, agent.agentId),
)
.poll(async () => {
const cache = await readReplicaCache(page);
const timeline = cache?.hosts?.find(
(host) => host.timeline?.agentId === agent.agentId,
)?.timeline;
return timeline?.items?.length === 50;
})
.toBe(true);
await page.reload();
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function waitForPersistedCanonicalTimelineRange(
page: Page,
agentId: string,
): Promise<PersistedCanonicalTimelineRange> {
const readRange = async () => {
const cache = await readReplicaCache(page);
if (cache?.version !== 6) return null;
const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range;
if (
typeof range?.epoch !== "string" ||
typeof range.startSeq !== "number" ||
typeof range.endSeq !== "number"
) {
return null;
}
return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq };
};
await expect.poll(readRange).not.toBeNull();
const range = await readRange();
if (!range) {
throw new Error(`Persisted canonical timeline range is missing for ${agentId}`);
}
return range;
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -40,12 +40,20 @@ export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): Timeli
};
}
export function expectOneTailCheckWithoutCatchUpPages(
export function expectOneResumeCheckWithoutTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
}
export function expectResumeOverflowFallsBackToOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
}
export async function disconnectViewedTimeline(
@@ -56,7 +64,7 @@ export async function disconnectViewedTimeline(
await expectReconnectingToastVisible(page);
}
export async function restoreViewedTimelineWithHeldTail(
export async function restoreViewedTimelineWithHeldResponse(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
+2
View File
@@ -48,6 +48,8 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@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",
+82
View File
@@ -0,0 +1,82 @@
const fs = require("node:fs");
const path = require("node:path");
const {
IOSConfig,
withAppDelegate,
withDangerousMod,
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'";
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");
}
return configured.replace(
REACT_NATIVE_START_END,
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
);
}
function configurePasteInputBridgingHeader(contents) {
if (contents.includes(BRIDGING_HEADER_IMPORT)) {
return 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") {
throw new Error("Paste input setup requires a Swift AppDelegate");
}
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
return modConfig;
});
const withPods = withPodfile(withDelegate, (modConfig) => {
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
return modConfig;
});
return withPasteInputBridgingHeader(withPods);
}
function withPasteInputBridgingHeader(config) {
return withDangerousMod(config, [
"ios",
async (modConfig) => {
const projectRoot = modConfig.modRequest.projectRoot;
const sourceRoot = IOSConfig.Paths.getSourceRoot(projectRoot);
const projectName = IOSConfig.XcodeUtils.getProjectName(projectRoot);
const headerPath = path.join(sourceRoot, `${projectName}-Bridging-Header.h`);
const contents = fs.readFileSync(headerPath, "utf8");
fs.writeFileSync(headerPath, configurePasteInputBridgingHeader(contents));
return modConfig;
},
]);
}
module.exports = withPasteInput;
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
const {
configurePasteInputAppDelegate,
configurePasteInputBridgingHeader,
configurePasteInputPodfile,
} = 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");
const configured = configurePasteInputAppDelegate(source);
expect(configured).toContain(
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
);
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
});
it("exposes the Objective-C module to Swift through the bridging header", () => {
const source = "// Swift bridging header\n";
const configured = configurePasteInputBridgingHeader(source);
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", () => {
const source =
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
const configured = configurePasteInputPodfile(source);
expect(configured).toContain(
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
);
expect(configurePasteInputPodfile(configured)).toBe(configured);
});
});
@@ -3,7 +3,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
describe("Android AsyncStorage configuration", () => {
it("writes a 10 MiB database limit into Gradle properties", () => {
it("writes a 64 MiB database limit into Gradle properties", () => {
const appDirectory = path.resolve(import.meta.dirname, "..");
const output = execFileSync(
process.execPath,
@@ -11,7 +11,7 @@ describe("Android AsyncStorage configuration", () => {
"-e",
`
const plugin = require("./plugins/with-android-async-storage-size");
const config = plugin({ name: "Paseo", slug: "paseo" }, 10);
const config = plugin({ name: "Paseo", slug: "paseo" }, 64);
const mod = config.mods.android.gradleProperties;
mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config })
.then((result) => process.stdout.write(JSON.stringify(result.modResults)));
@@ -21,7 +21,7 @@ describe("Android AsyncStorage configuration", () => {
);
expect(JSON.parse(output)).toEqual([
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" },
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "64" },
]);
});
});
+2
View File
@@ -127,6 +127,7 @@ import {
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
@@ -270,6 +271,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
<PluginCatalogSync serverId={daemon.serverId} client={client} />
</SessionProvider>
);
}
@@ -41,6 +41,7 @@ function KnownHostRoute() {
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
</ThemedStack>
);
@@ -0,0 +1,5 @@
import { PluginSurfaceScreen } from "@/plugins";
export default function PluginSurfaceRoute() {
return <PluginSurfaceScreen />;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
describe("agent attachment pill content", () => {
it("presents external resources with their provider identity", () => {
const content = getAgentAttachmentPillContent(
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
i18n.t,
);
expect(content.title).toBe("Plugin attachments");
expect(content.subtitle).toBe("Linear issue ENG-123");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import React from "react";
import {
CircleDot,
FileText,
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`,
};
case "text":
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
title: attachment.externalResource.title,
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
};
}
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
+2
View File
@@ -3,6 +3,7 @@ import type {
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
import type { PluginResourceComposerAttachment } from "@/plugins/attachments";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| PluginResourceComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -0,0 +1,90 @@
/**
* @vitest-environment jsdom
*/
import React, { memo } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list.web";
interface Item {
id: string;
}
const items: Item[] = [{ id: "one" }];
function keyExtractor(item: Item): string {
return item.id;
}
function onDragEnd() {}
let root: Root | null = null;
let container: HTMLElement | null = null;
let rowRenderCount = 0;
const Row = memo(function Row({
dragHandleProps: _dragHandleProps,
}: {
dragHandleProps?: DraggableListDragHandleProps;
}) {
rowRenderCount += 1;
return <div>row</div>;
});
function renderRow(info: DraggableRenderItemInfo<Item>) {
return <Row dragHandleProps={info.dragHandleProps} />;
}
function renderReplacementRow(info: DraggableRenderItemInfo<Item>) {
return <Row dragHandleProps={info.dragHandleProps} />;
}
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
rowRenderCount = 0;
});
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
vi.unstubAllGlobals();
});
describe("DraggableList web row stability", () => {
it("keeps drag handle props stable when the render callback changes", () => {
act(() => {
root?.render(
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={onDragEnd}
useDragHandle
/>,
);
});
expect(rowRenderCount).toBe(1);
act(() => {
root?.render(
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderReplacementRow}
onDragEnd={onDragEnd}
useDragHandle
/>,
);
});
expect(rowRenderCount).toBe(1);
});
});
@@ -34,6 +34,61 @@ const DRAG_ACTIVATION_CONFIG = {
touchHoldTolerance: 8,
};
function areRecordsEqual(
left: Record<string, unknown> | undefined,
right: Record<string, unknown> | undefined,
): boolean {
if (left === right) return true;
if (!left || !right) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => Object.is(left[key], right[key]));
}
function useShallowStableRecord(
value: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const stableRef = useRef(value);
if (!areRecordsEqual(stableRef.current, value)) {
stableRef.current = value;
}
return stableRef.current;
}
function useStableListenerRecord(
listeners: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const latestListenersRef = useRef(listeners);
latestListenersRef.current = listeners;
const listenerKeys = Object.keys(listeners ?? {}).sort();
const listenerKeySignature = listenerKeys.join("\u0000");
const stableListenersRef = useRef<{
keySignature: string;
listeners: Record<string, unknown> | undefined;
}>(undefined);
if (stableListenersRef.current?.keySignature !== listenerKeySignature) {
stableListenersRef.current = {
keySignature: listenerKeySignature,
listeners:
listenerKeys.length === 0
? undefined
: Object.fromEntries(
listenerKeys.map((key) => [
key,
(...args: unknown[]) => {
const listener = latestListenersRef.current?.[key];
if (typeof listener === "function") {
return Reflect.apply(listener, undefined, args);
}
},
]),
),
};
}
return stableListenersRef.current.listeners;
}
interface SortableItemProps<T> {
id: string;
item: T;
@@ -92,19 +147,28 @@ function SortableItemInner<T>({
}),
[combinedTransform, transition, isDragging],
);
const stableAttributes = useShallowStableRecord(attributes as unknown as Record<string, unknown>);
const stableListeners = useStableListenerRecord(
listeners as unknown as Record<string, unknown> | undefined,
);
const dragHandleProps = useMemo(
() =>
useDragHandle
? {
attributes: stableAttributes,
listeners: stableListeners,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
[setActivatorNodeRef, stableAttributes, stableListeners, useDragHandle],
);
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag,
isActive: activeId === id,
dragHandleProps: useDragHandle
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
dragHandleProps,
};
const wrapperProps = useDragHandle
@@ -75,6 +75,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { PluginSidebarItems } from "@/plugins";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -681,6 +682,7 @@ function MobileSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
</View>
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
@@ -899,6 +901,7 @@ function DesktopSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems />
</View>
</View>
@@ -40,7 +40,6 @@ export interface ProjectEditSheetProps {
export function ProjectEditSheet({
visible,
onClose,
serverId,
projectId,
projectViewKey,
client,
@@ -60,7 +59,6 @@ export function ProjectEditSheet({
submitProjectEdit({ client, projectId, submission }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] });
toast.show(t("settings.project.edit.savedToast"), { variant: "success" });
onClose();
},
@@ -33,6 +33,7 @@ import {
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
@@ -616,6 +617,7 @@ function ProjectMenuItems({
function WorkspaceRowRightGroup({
workspace,
backdrop,
isHovered,
isTouchPlatform,
isCreating,
@@ -634,6 +636,7 @@ function WorkspaceRowRightGroup({
onTogglePin,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
@@ -681,7 +684,10 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -1124,6 +1130,7 @@ function WorkspaceRowInner({
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -1168,7 +1175,7 @@ function WorkspaceRowInner({
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -1178,6 +1185,7 @@ function WorkspaceRowInner({
>
<WorkspaceRowRightGroup
workspace={workspace}
backdrop={backdrop}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
@@ -1941,6 +1949,30 @@ export function SidebarWorkspaceList({
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
const handlePinnedWorkspaceReorder = useCallback(
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
const currentOrder = getPinnedWorkspaceOrder();
if (
!hasVisibleOrderChanged({
currentOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
})
) {
return;
}
setPinnedWorkspaceOrder(
mergeWithRemainder({
currentOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
}),
);
},
[getPinnedWorkspaceOrder, setPinnedWorkspaceOrder],
);
// Status mode drops the project grouping, so its rows carry their own project
// icon. Project mode fetches the same icons inside ProjectModeList for its
// project headers, so only the active mode requests them.
@@ -1964,7 +1996,10 @@ export function SidebarWorkspaceList({
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/>
) : (
<ProjectModeList
@@ -1985,6 +2020,7 @@ export function SidebarWorkspaceList({
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
/>
);
@@ -2001,7 +2037,10 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
}: {
statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups;
@@ -2012,17 +2051,25 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
listHeaderComponent?: ReactElement | null;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}) {
const showShortcutBadges = useShowShortcutBadges();
const pinnedWorkspaces = useMemo(
() =>
pinnedGroups.pinnedChats.flatMap((workspace) => {
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : [];
}),
[pinnedGroups.pinnedChats, workspaceEntriesByKey],
);
return (
<SidebarStatusWorkspaceList
groups={statusGroups}
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => {
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : [];
})}
pinnedWorkspaces={pinnedWorkspaces}
projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
@@ -2030,7 +2077,10 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/>
);
}
@@ -2053,12 +2103,14 @@ function ProjectModeList({
supportsMultiplicityByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
}) {
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { t } = useTranslation();
@@ -2296,10 +2348,14 @@ function ProjectModeList({
);
const renderPinnedChat = useCallback(
(workspace: SidebarWorkspacePlacement) => {
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
@@ -2316,6 +2372,9 @@ function ProjectModeList({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
);
},
@@ -2341,7 +2400,20 @@ function ProjectModeList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedChats.map(renderPinnedChat)}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedChats}
keyExtractor={workspaceKeyExtractor}
renderItem={renderPinnedChat}
onDragEnd={onPinnedWorkspaceReorder}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
containerStyle={styles.workspaceListContainer}
/>
{canTogglePinnedChats ? (
<SidebarGroupToggleRow
expanded={pinnedChatsExpanded}
@@ -9,6 +9,7 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { buildSidebarProjection } from "./sidebar-projection";
@@ -41,6 +42,7 @@ export function SidebarModelProvider({
(state) => state.collapsedStatusGroupKeys,
);
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleProjectCollapsed,
);
@@ -58,6 +60,7 @@ export function SidebarModelProvider({
buildSidebarProjection({
projects: list.projects,
pinnedKeys,
pinnedWorkspaceOrder,
workspaceEntriesByKey: projectionWorkspaceEntriesByKey,
projectNamesByViewKey: list.projectNamesByViewKey,
groupMode,
@@ -73,6 +76,7 @@ export function SidebarModelProvider({
list.projects,
pinnedCollapsed,
pinnedKeys,
pinnedWorkspaceOrder,
projectionWorkspaceEntriesByKey,
],
);
@@ -66,6 +66,7 @@ function projectionInput(options?: {
pinnedWorkspaceKeys: [pinned.placement.workspaceKey],
pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" },
},
pinnedWorkspaceOrder: [],
workspaceEntriesByKey: new Map([
[pinned.entry.workspaceKey, pinned.entry],
[unpinned.entry.workspaceKey, unpinned.entry],
@@ -24,6 +24,7 @@ export interface SidebarProjection {
export function buildSidebarProjection(input: {
projects: SidebarProjectEntry[];
pinnedKeys: PinnedSidebarKeys;
pinnedWorkspaceOrder: string[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByViewKey: Map<string, string>;
groupMode: SidebarGroupMode;
@@ -34,6 +35,7 @@ export function buildSidebarProjection(input: {
const pinnedGroups = splitPinnedSidebarGroups({
projects: input.projects,
keys: input.pinnedKeys,
pinnedWorkspaceOrder: input.pinnedWorkspaceOrder,
});
const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys);
const statusGroups =
@@ -1,7 +1,23 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import {
memo,
useCallback,
useMemo,
useState,
type MutableRefObject,
type ReactNode,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import {
View,
Text,
Pressable,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
@@ -10,6 +26,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
@@ -59,6 +76,9 @@ import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -85,6 +105,12 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const EMPTY_SHORTCUT_INDEX = new Map<string, number>();
function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string {
return workspace.workspaceKey;
}
interface StatusWorkspaceListProps {
groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[];
@@ -95,7 +121,10 @@ interface StatusWorkspaceListProps {
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
listHeaderComponent?: ReactNode;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}
export function SidebarStatusWorkspaceList({
@@ -108,7 +137,10 @@ export function SidebarStatusWorkspaceList({
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
}: StatusWorkspaceListProps) {
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
@@ -124,7 +156,44 @@ export function SidebarStatusWorkspaceList({
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces);
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
: EMPTY_SHORTCUT_INDEX;
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
<StatusWorkspaceRow
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
),
[
hostBadgeByServerId,
onToggleWorkspacePin,
onWorkspacePress,
projectIconByProjectViewKey,
showShortcutBadges,
statusShortcutIndex,
supportsPinningByServerId,
],
);
const content = (
<>
{pinnedWorkspaces.length > 0 ? (
@@ -132,23 +201,18 @@ export function SidebarStatusWorkspaceList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces}
keyExtractor={statusWorkspaceKeyExtractor}
renderItem={renderPinnedWorkspace}
onDragEnd={onPinnedWorkspaceReorder}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
/>
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
expanded={pinnedWorkspacesExpanded}
@@ -423,6 +487,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -439,6 +506,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
*/
inStatusGroup?: boolean;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
@@ -465,6 +535,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
onPress={handlePress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
});
@@ -482,6 +555,9 @@ function StatusWorkspaceRowWithMenu({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onPress,
drag,
isDragging = false,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -499,6 +575,9 @@ function StatusWorkspaceRowWithMenu({
*/
inStatusGroup?: boolean;
onPress: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const { t } = useTranslation();
const toast = useToast();
@@ -620,6 +699,9 @@ function StatusWorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -635,7 +717,53 @@ function StatusWorkspaceRowWithMenu({
);
}
function StatusWorkspaceRowInner({
interface StatusWorkspaceRowInnerProps {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/** Pinned rows are flat under their own header; status-group rows indent from theirs. */
inStatusGroup?: boolean;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
function StatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
if (props.drag) {
return <DraggableStatusWorkspaceRowInner {...props} drag={props.drag} />;
}
return <StatusWorkspaceRowInnerContent {...props} />;
}
function DraggableStatusWorkspaceRowInner(
props: StatusWorkspaceRowInnerProps & { drag: () => void },
) {
const dragInteraction = useLongPressDragInteraction({
drag: props.drag,
menuController: null,
});
return <StatusWorkspaceRowInnerContent {...props} dragInteraction={dragInteraction} />;
}
function StatusWorkspaceRowInnerContent({
workspace,
hostBadge,
projectName,
@@ -658,47 +786,51 @@ function StatusWorkspaceRowInner({
onTogglePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
isDragging = false,
dragHandleProps,
dragInteraction,
}: StatusWorkspaceRowInnerProps & {
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
}) {
const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const handlePressIn = useCallback(() => setIsPressed(true), []);
const handlePressOut = useCallback(() => setIsPressed(false), []);
const didLongPressRef = dragInteraction?.didLongPressRef;
const startDragPress = dragInteraction?.handlePressIn;
const moveDragPress = dragInteraction?.handleTouchMove;
const endDragPress = dragInteraction?.handlePressOut;
const handlePress = useCallback(() => {
if (didLongPressRef?.current) {
didLongPressRef.current = false;
return;
}
onPress();
}, [didLongPressRef, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
startDragPress?.(event);
},
[startDragPress],
);
const handlePressOut = useCallback(() => {
setIsPressed(false);
endDragPress?.();
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
@@ -720,9 +852,17 @@ function StatusWorkspaceRowInner({
selected,
isHovered,
inStatusGroup,
isDragging,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
@@ -749,8 +889,9 @@ function StatusWorkspaceRowInner({
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn}
onTouchMove={moveDragPress}
onPressOut={handlePressOut}
onPress={onPress}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -759,7 +900,7 @@ function StatusWorkspaceRowInner({
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
@@ -769,6 +910,7 @@ function StatusWorkspaceRowInner({
{renderSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
showBase={showTrailing}
showKebab={showKebabInSlot}
@@ -798,6 +940,7 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({
workspace,
backdrop,
trailing,
showBase,
showKebab,
@@ -816,6 +959,7 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
showBase: boolean;
showKebab: boolean;
@@ -839,7 +983,10 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -867,17 +1014,20 @@ function getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
isDragging: boolean;
}) {
return [
styles.workspaceRow,
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -972,6 +1122,14 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
@@ -70,7 +70,11 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
const scrimColorMappings: Record<SidebarSurfaceBackdrop, (theme: Theme) => { color: string }> = {
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
surface2: (theme) => ({ color: theme.colors.surface2 }),
};
export function SidebarWorkspaceRowFrame({
workspace,
@@ -109,7 +113,7 @@ export function SidebarWorkspaceRowFrame({
disabled={contextMenuOpen}
>
{children({
isHovered: isHovered && !contextMenuOpen,
isHovered: isHovered && !contextMenuOpen && !isDragging,
contextMenuOpen,
onContextMenuOpenChange: handleContextMenuOpenChange,
hoverHandlers,
@@ -458,18 +462,18 @@ export function SidebarWorkspaceTrailingActionBase({
export function SidebarWorkspaceTrailingActionOverlay({
visible,
scrim = false,
scrimBackdrop,
children,
}: {
visible: boolean;
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
scrim?: boolean;
scrimBackdrop?: SidebarSurfaceBackdrop;
children: ReactNode;
}) {
if (!visible || !children) return null;
return (
<>
{scrim ? <TrailingActionScrim /> : null}
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
</>
);
@@ -484,14 +488,21 @@ export function SidebarWorkspaceTrailingActionOverlay({
* Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose:
* the fade has to start before the diff stat does or the diff's left edge cuts off hard.
*/
function TrailingActionScrim() {
function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) {
// useId's output contains characters that are not legal inside url(#...) — React 19 wraps
// ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all.
// Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={sidebarWorkspaceRowStyles.trailingActionScrim} pointerEvents="none">
<ThemedTrailingActionScrimSvg gradientId={gradientId} uniProps={scrimColorMapping} />
<View
style={sidebarWorkspaceRowStyles.trailingActionScrim}
pointerEvents="none"
testID="sidebar-workspace-trailing-scrim"
>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={scrimColorMappings[backdrop]}
/>
</View>
);
}
@@ -6,6 +6,7 @@ import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
@@ -310,6 +311,7 @@ function WorkspaceRowBody({
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...(draggable ? dragAttributes : {})}
@@ -353,7 +355,7 @@ function WorkspaceRowBody({
workspace={workspace}
hostBadge={hostBadge}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -362,6 +364,7 @@ function WorkspaceRowBody({
>
<WorkspaceRowTrailingActions
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
@@ -389,6 +392,7 @@ function WorkspaceRowBody({
function WorkspaceRowTrailingActions({
workspace,
backdrop,
trailing,
isHovered,
isTouchPlatform,
@@ -406,6 +410,7 @@ function WorkspaceRowTrailingActions({
onRename,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
isHovered: boolean;
isTouchPlatform: boolean;
@@ -450,7 +455,10 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -9,6 +9,7 @@ export function useLongPressDragInteraction(input: {
drag: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const { drag, menuController } = input;
const didLongPressRef = useRef(false);
const dragArmedRef = useRef(false);
const dragActivatedRef = useRef(false);
@@ -32,20 +33,20 @@ export function useLongPressDragInteraction(input: {
}, []);
const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) {
if (!menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
input.menuController.setAnchorRect({
menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
});
input.menuController.setOpen(true);
menuController.setOpen(true);
menuOpenedRef.current = true;
didLongPressRef.current = true;
}, [input.menuController]);
}, [menuController]);
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
@@ -87,10 +88,10 @@ export function useLongPressDragInteraction(input: {
dragActivatedRef.current = true;
didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {});
input.drag();
drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController) {
if (!menuController) {
return;
}
@@ -112,7 +113,7 @@ export function useLongPressDragInteraction(input: {
void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, input, openContextMenuAtStartPoint]);
}, [clearTimers, drag, menuController, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
@@ -438,7 +438,8 @@ export function WorkspaceSetupDialog() {
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
onChangeText={chatDraft.editText}
textReplacementKey={chatDraft.textReplacementKey}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
+29
View File
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
});
expect(externalUrlCalls).toEqual([issueItem.url]);
});
it("opens plugin resource URLs through the external url opener", () => {
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: {
kind: "plugin_resource",
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
},
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]);
});
});
describe("toggleGithubAttachment", () => {
@@ -10,6 +10,7 @@ import {
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "plugin_resource") {
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
@@ -224,11 +224,25 @@ describe("useAgentInputDraft live contract", () => {
thinkingOptionId: "high",
});
const hydratedReplacementKey = getLatest().textReplacementKey;
await act(async () => {
getLatest().setText("hello world");
getLatest().editText("hello world");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
expect(getLatest().textReplacementKey).toBe(hydratedReplacementKey);
await act(async () => {
getLatest().replaceText("replacement text");
});
expect(getLatest().textReplacementKey).not.toBe(hydratedReplacementKey);
await act(async () => {
getLatest().editText("hello world");
});
await act(async () => {
root!.unmount();
});
@@ -418,7 +432,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("with attachment");
getLatest().editText("with attachment");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
@@ -500,7 +514,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("queued message");
getLatest().editText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
@@ -554,7 +568,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("queued message");
getLatest().editText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
});
@@ -570,7 +584,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("draft again");
getLatest().editText("draft again");
});
await act(async () => {
+17 -3
View File
@@ -52,7 +52,9 @@ type DraftComposerState = UseAgentFormStateResult & {
export interface AgentInputDraft {
text: string;
setText: (text: string) => void;
editText: (text: string) => void;
replaceText: (text: string) => void;
textReplacementKey: string;
attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
@@ -84,6 +86,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const [textReplacementRevision, setTextReplacementRevision] = useState(0);
const text = draft?.text ?? "";
const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey;
@@ -107,13 +110,21 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey],
);
const setText = useCallback(
const editText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
},
[saveDraft],
);
const replaceText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
setTextReplacementRevision((revision) => revision + 1);
},
[saveDraft],
);
const setAttachments = useCallback(
(updater: AttachmentUpdater) => {
saveDraft((current) => ({
@@ -136,6 +147,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
void (async () => {
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
setTextReplacementRevision((revision) => revision + 1);
setHydratedDraftKey(draftKey);
}
})();
@@ -264,7 +276,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
return {
text,
setText,
editText,
replaceText,
textReplacementKey: `${draftKey}:${textReplacementRevision}`,
attachments,
setAttachments,
clear,
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear;
const setDraftText = draftInput.setText;
const replaceDraftText = draftInput.replaceText;
const setDraftAttachments = draftInput.setAttachments;
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null;
@@ -587,7 +587,7 @@ export function WorkspaceDraftAgentTab({
return;
}
autoSubmitKeyRef.current = submitKey;
setDraftText("");
replaceDraftText("");
setDraftAttachments([]);
const preparedAttempt =
initialCreateAttempt?.clientMessageId === submission.clientMessageId
@@ -604,7 +604,7 @@ export function WorkspaceDraftAgentTab({
cwd: submission.cwd,
});
void createPromise.catch(() => {
setDraftText(submission.text);
replaceDraftText(submission.text);
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
autoSubmitKeyRef.current = null;
});
@@ -617,7 +617,7 @@ export function WorkspaceDraftAgentTab({
isReadyForPendingAutoSubmit,
serverId,
setDraftAttachments,
setDraftText,
replaceDraftText,
workspaceId,
]);
@@ -699,7 +699,8 @@ export function WorkspaceDraftAgentTab({
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
onChangeText={draftInput.editText}
textReplacementKey={draftInput.textReplacementKey}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
+85 -11
View File
@@ -125,6 +125,8 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { readClipboardImage } from "./clipboard-image";
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
@@ -440,6 +442,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
onRemove,
});
}
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -846,6 +862,7 @@ interface ComposerProps {
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
textReplacementKey: string;
attachments: UserComposerAttachment[];
attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
@@ -1054,6 +1071,7 @@ export function Composer({
blurOnSubmit = false,
value,
onChangeText,
textReplacementKey,
attachments,
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
@@ -1151,6 +1169,7 @@ export function Composer({
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
@@ -1158,11 +1177,30 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const pluginAttachments = usePluginAttachmentPicker({
serverId,
client,
connected: isConnected,
attachments,
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
const replaceUserInput = useCallback(
(text: string, selection?: { start: number; end: number }) => {
if (messageInputRef.current) {
messageInputRef.current.replaceText(text, selection);
return;
}
onChangeText(text);
},
[onChangeText],
);
const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) {
@@ -1173,7 +1211,7 @@ export function Composer({
messageInputRef.current?.blur();
}
clearDraft("sent");
setUserInput("");
replaceUserInput("");
setSelectedAttachments([]);
resetSuppression();
setSendError(null);
@@ -1194,14 +1232,14 @@ export function Composer({
onClientSlashCommand,
resetSuppression,
setSelectedAttachments,
setUserInput,
replaceUserInput,
],
);
const autocomplete = useAgentAutocomplete({
userInput,
cursorIndex,
setUserInput,
setUserInput: replaceUserInput,
serverId,
agentId,
draftConfig: commandDraftConfig,
@@ -1356,7 +1394,7 @@ export function Composer({
});
if (!result.queued) return;
setUserInput("");
replaceUserInput("");
setSelectedAttachments([]);
resetSuppression();
clearSentAttachments(queuedAttachments);
@@ -1367,7 +1405,7 @@ export function Composer({
queueWriter,
resetSuppression,
setSelectedAttachments,
setUserInput,
replaceUserInput,
],
);
@@ -1398,7 +1436,7 @@ export function Composer({
await submitMessage(submitText, submitAttachments);
},
clearDraft,
setUserInput,
setUserInput: replaceUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
},
@@ -1423,7 +1461,7 @@ export function Composer({
isAgentRunning,
queueMessage,
setSelectedAttachments,
setUserInput,
replaceUserInput,
submitBehavior,
submitMessage,
t,
@@ -1484,6 +1522,30 @@ export function Composer({
}
}, [addImages, t]);
const handleNativePasteImages = useCallback(
(files: readonly NativePastedFile[]) => {
setPendingNativeImagePastes((pending) => pending + 1);
void pickAndPersistImages({
pickImages: async () => normalizeNativePastedImages(files),
persister: composerImageAttachmentPersister,
})
.then((newImages) => {
if (newImages.length > 0) {
addImages(newImages);
}
return undefined;
})
.catch((error) => {
console.error("[Composer] Failed to persist pasted image:", error);
toastErrorRef.current(t("composer.errors.pasteImageFailed"));
})
.finally(() => {
setPendingNativeImagePastes((pending) => Math.max(0, pending - 1));
});
},
[addImages, t],
);
const uploadPickedFiles = useCallback(
async (files: PickedFile[]) => {
if (files.length === 0) return;
@@ -1686,10 +1748,10 @@ export function Composer({
queue: queueWriter,
});
if (!result) return;
setUserInput(result.text);
replaceUserInput(result.text);
setSelectedAttachments(result.attachments);
},
[agentId, queueWriter, setSelectedAttachments, setUserInput],
[agentId, queueWriter, replaceUserInput, setSelectedAttachments],
);
const handleSendQueuedNow = useCallback(
@@ -1922,6 +1984,7 @@ export function Composer({
setIsGithubPickerOpen(true);
},
},
...pluginAttachments.menuItems,
{
id: "file",
label: t("composer.attachments.addFile"),
@@ -1932,7 +1995,14 @@ export function Composer({
},
);
return items;
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
}, [
forgePresentation,
handlePasteImage,
handlePickFile,
handlePickImage,
pluginAttachments.menuItems,
t,
]);
const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => {
@@ -2073,7 +2143,8 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null);
const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile;
const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
const isSubmitDisabled =
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
@@ -2142,6 +2213,7 @@ export function Composer({
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
@@ -2167,6 +2239,7 @@ export function Composer({
attachmentSlot={attachmentTray}
inputMode={inputMode}
readOnly={readOnly}
textReplacementKey={textReplacementKey}
submitLabel={submitLabel}
/>
<Combobox
@@ -2189,6 +2262,7 @@ export function Composer({
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
{pluginAttachments.picker}
</View>
</View>
</View>
+63 -34
View File
@@ -56,6 +56,9 @@ import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import { ComposerTextInput } from "./text-input";
import type { ComposerTextInputHandle } from "./text-input-types";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
@@ -105,6 +108,7 @@ export interface MessageInputProps {
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef?: (node: View | null) => void;
onAddImages?: (images: ImageAttachment[]) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
client: DaemonClient | null;
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean;
@@ -147,6 +151,8 @@ export interface MessageInputProps {
inputMode?: ComposerInputMode;
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
readOnly?: boolean;
/** Changes only when application state must replace native-owned text. */
textReplacementKey: string;
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
submitLabel?: string;
}
@@ -154,6 +160,8 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
getText: () => string;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
@@ -397,12 +405,9 @@ function handleDesktopKeyPressImpl(
ctx.handleDefaultSendAction();
}
function getTextInputNativeElement(
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): HTMLElement | null {
function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTMLElement | null {
if (!current) return null;
const handle = current as TextInput & { getNativeRef?: () => unknown };
const native = typeof handle.getNativeRef === "function" ? handle.getNativeRef() : current;
const native = typeof current.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null;
}
@@ -478,9 +483,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
}
function useAutoFocusOnWebEffect(
textInputRef: React.MutableRefObject<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>,
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>,
autoFocus: boolean,
autoFocusKey: string | undefined,
): void {
@@ -588,8 +591,8 @@ function FocusHint({
interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textInputRef: React.Ref<TextInput>;
textInputStyle: React.ComponentProps<typeof ThemedTextInput>["style"];
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: React.ComponentProps<typeof TextInput>["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
placeholder: string;
accessibilityLabel: string;
@@ -602,6 +605,8 @@ interface ComposerTextSurfaceProps {
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
onPasteError: (message: string) => void;
focusHintVisible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
focusHintLabel: string;
@@ -624,13 +629,12 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
}
return (
<View style={styles.textInputScrollWrapper}>
<ThemedTextInput
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
value={props.value}
text={props.value}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
uniProps={textInputPlaceholderColorMapping}
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
@@ -641,6 +645,8 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
editable={props.editable}
onKeyPress={props.onKeyPress}
onSelectionChange={props.onSelectionChange}
onPasteImages={props.onPasteImages}
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
<FocusHint
@@ -904,7 +910,7 @@ interface QueueMessageContext {
attachments: ComposerAttachment[];
cwd: string;
onQueue: ((payload: MessagePayload) => void) | undefined;
onChangeText: (text: string) => void;
replaceText: (text: string) => void;
onMinimizeHeight: () => void;
}
@@ -913,7 +919,7 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return;
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.onChangeText("");
ctx.replaceText("");
ctx.onMinimizeHeight();
}
@@ -965,9 +971,7 @@ function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
}
function getWebTextAreaImpl(
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): TextAreaHandle | null {
function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHandle | null {
if (!current) return null;
const candidate = current as { getNativeRef?: () => unknown };
if (typeof candidate.getNativeRef === "function") {
@@ -1019,6 +1023,7 @@ interface ResolvedMessageInputProps {
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef: ((node: View | null) => void) | undefined;
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
placeholder: string | undefined;
@@ -1044,6 +1049,7 @@ interface ResolvedMessageInputProps {
attachmentSlot: React.ReactNode;
inputMode: ComposerInputMode;
readOnly: boolean;
textReplacementKey: string;
submitLabel: string | undefined;
}
@@ -1065,6 +1071,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentMenuItems: props.attachmentMenuItems,
onAttachButtonRef: props.onAttachButtonRef,
onAddImages: props.onAddImages,
onPasteImages: props.onPasteImages,
client: props.client,
isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder,
@@ -1090,6 +1097,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentSlot: props.attachmentSlot,
inputMode: props.inputMode ?? "chat",
readOnly: props.readOnly ?? false,
textReplacementKey: props.textReplacementKey,
submitLabel: props.submitLabel,
};
}
@@ -1119,6 +1127,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentMenuItems,
onAttachButtonRef,
onAddImages,
onPasteImages,
client,
isReadyForDictation,
placeholder,
@@ -1144,6 +1153,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentSlot,
inputMode,
readOnly,
textReplacementKey,
submitLabel,
} = resolveMessageInputProps(props);
const mode = resolveComposerInputMode(inputMode);
@@ -1161,18 +1171,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
null,
);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const valueRef = useRef(value);
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
valueRef.current = nextText;
textInputRef.current?.replaceText(nextText, selection);
onChangeText(nextText);
},
[onChangeText],
);
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus();
},
blur: () => {
textInputRef.current?.blur?.();
textInputRef.current?.blur();
},
getText: () => textInputRef.current?.getText() ?? valueRef.current,
replaceText,
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
@@ -1191,7 +1212,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore(
useCallback(
(state) => {
@@ -1205,8 +1225,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
useEffect(() => {
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey;
valueRef.current = value;
}, [value]);
textInputRef.current?.replaceText(value);
}, [textReplacementKey, value]);
useEffect(() => {
return () => {
@@ -1226,13 +1249,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
onQueue,
onSubmit,
onChangeText,
replaceText,
attachments,
cwd,
autoSend,
});
},
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
[replaceText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
@@ -1413,7 +1436,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleSendMessage = useCallback(
() =>
sendMessageImpl({
value: valueRef.current,
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
hasExternalContent,
allowEmptySubmit,
@@ -1438,14 +1461,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleQueueMessage = useCallback(
() =>
queueMessageImpl({
value: valueRef.current,
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
cwd,
onQueue,
onChangeText,
replaceText,
onMinimizeHeight: minimizeInputHeight,
}),
[attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
);
const handleDefaultSendAction = useCallback(() => {
@@ -1618,6 +1641,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onFocusChange?.(false);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
console.error("[MessageInput] Native paste failed:", message);
toast.error(t("composer.errors.pasteImageFailed"));
},
[t, toast],
);
const attachButtonStyle = useCallback(
({ hovered }: { hovered?: boolean }) => [
styles.attachButton,
@@ -1732,6 +1763,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
@@ -1982,11 +2015,7 @@ const ThemedMicOff = withUnistyles(MicOff);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const textInputPlaceholderColorMapping = (theme: Theme) => ({
placeholderTextColor: theme.colors.surface4,
});
@@ -173,7 +173,7 @@ describe("dictation transcript behavior", () => {
defaultSendBehavior: "interrupt",
isAgentRunning: false,
onQueue: undefined,
onChangeText: (text) => actions.push(`change:${text}`),
replaceText: (text) => actions.push(`replace:${text}`),
onSubmit: (payload) => actions.push(`submit:${payload.text}`),
attachments: [],
cwd: "/repo",
@@ -181,7 +181,7 @@ describe("dictation transcript behavior", () => {
});
expect(actions).toEqual([
"change:typed context spoken prompt",
"replace:typed context spoken prompt",
"submit:typed context spoken prompt",
]);
});
+4 -4
View File
@@ -52,7 +52,7 @@ interface DictationTranscriptContext {
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmit: (payload: MessagePayload) => void;
onChangeText: (text: string) => void;
replaceText: (text: string) => void;
attachments: MessagePayload["attachments"];
cwd: string;
autoSend: boolean;
@@ -64,15 +64,15 @@ export function applyDictationTranscript(text: string, ctx: DictationTranscriptC
const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`;
if (!ctx.autoSend) {
ctx.onChangeText(nextValue);
ctx.replaceText(nextValue);
return;
}
ctx.onChangeText(nextValue);
ctx.replaceText(nextValue);
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.onChangeText("");
ctx.replaceText("");
return;
}
@@ -0,0 +1,20 @@
import type { TextInputProps } from "react-native";
import type { NativePastedFile } from "@/composer/native-pasted-image";
export interface ComposerTextInputHandle {
focus: () => void;
blur: () => void;
getText: () => string;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
getNativeRef?: () => unknown;
}
export interface ComposerTextInputProps extends Omit<
TextInputProps,
"defaultValue" | "onChangeText" | "value"
> {
text: string;
onChangeText: (text: string) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
onPasteError?: (message: string) => void;
}
+1
View File
@@ -0,0 +1 @@
export * from "./text-input.native";
@@ -0,0 +1,72 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import PasteInput, {
type PastedFile,
type PasteTextInputInstance,
} from "@mattermost/react-native-paste-input";
import { withUnistyles } from "react-native-unistyles";
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
type NativeInputInstance = PasteTextInputInstance & {
blur: () => void;
focus: () => void;
setNativeProps: (props: { text: string }) => void;
};
const ThemedPasteInput = withUnistyles(PasteInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
}));
export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>(
function ComposerTextInputNative(
{ text, onChangeText, onPasteImages, onPasteError, ...props },
ref,
) {
const inputRef = useRef<NativeInputInstance | null>(null);
const textRef = useRef(text);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
inputRef.current?.setNativeProps({ text: nextText });
if (selection) {
inputRef.current?.setSelection(selection.start, selection.end);
}
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
const handleChangeText = useCallback(
(nextText: string) => {
textRef.current = nextText;
onChangeText(nextText);
},
[onChangeText],
);
const handlePaste = useCallback(
(error: string | null | undefined, files: PastedFile[]) => {
if (error) {
onPasteError?.(error);
return;
}
if (files.length > 0) {
onPasteImages?.(files);
}
},
[onPasteError, onPasteImages],
);
return (
<ThemedPasteInput
{...props}
ref={inputRef}
defaultValue={text}
onChangeText={handleChangeText}
onPaste={handlePaste}
/>
);
},
);
@@ -0,0 +1,148 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ComposerTextInput } from "./text-input.web";
interface MountedInput {
root: Root;
container: HTMLDivElement;
textarea: HTMLTextAreaElement;
}
interface TextRecorder {
changes: string[];
onChangeText: (text: string) => void;
}
const mountedInputs: MountedInput[] = [];
function mountInput(onChangeText: (text: string) => void): MountedInput {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<ComposerTextInput
text=""
multiline={true}
onChangeText={onChangeText}
testID="composer-input"
/>,
);
});
const textarea = container.querySelector("textarea");
if (!textarea) {
throw new Error("Composer text input did not render a textarea");
}
const mounted = { root, container, textarea };
mountedInputs.push(mounted);
return mounted;
}
function dispatchComposition(
textarea: HTMLTextAreaElement,
type: "compositionstart" | "compositionend",
) {
textarea.dispatchEvent(new CompositionEvent(type, { bubbles: true }));
}
function typeFromIme(textarea: HTMLTextAreaElement, text: string): void {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
if (!valueSetter) {
throw new Error("HTML textarea value setter is unavailable");
}
valueSetter.call(textarea, text);
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
}
function ignoreTextChange(): void {}
function createTextRecorder(): TextRecorder {
const changes: string[] = [];
return {
changes,
onChangeText: (text) => changes.push(text),
};
}
afterEach(() => {
for (const mounted of mountedInputs.splice(0)) {
act(() => mounted.root.unmount());
mounted.container.remove();
}
vi.useRealTimers();
});
describe("ComposerTextInput web IME composition", () => {
it("keeps the DOM-owned candidate during composition and reports the committed text", () => {
vi.useFakeTimers();
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
act(() => {
dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你好";
mounted.root.render(
<ComposerTextInput
text=""
multiline={true}
onChangeText={recorder.onChangeText}
placeholder="rerender while composing"
testID="composer-input"
/>,
);
});
expect(mounted.textarea.value).toBe("你好");
act(() => {
dispatchComposition(mounted.textarea, "compositionend");
vi.runAllTimers();
});
expect(recorder.changes).toEqual(["你好"]);
});
it("does not let a previous composition-end timer take ownership from a new composition", () => {
vi.useFakeTimers();
const mounted = mountInput(ignoreTextChange);
act(() => {
dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你";
dispatchComposition(mounted.textarea, "compositionend");
dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你好";
vi.runAllTimers();
mounted.root.render(
<ComposerTextInput
text="你"
multiline={true}
onChangeText={ignoreTextChange}
placeholder="second composition"
testID="composer-input"
/>,
);
});
expect(mounted.textarea.value).toBe("你好");
});
it("does not report committed text twice when the input event already reported it", () => {
vi.useFakeTimers();
const changes: string[] = [];
const mounted = mountInput((text) => changes.push(text));
act(() => {
dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "你好");
dispatchComposition(mounted.textarea, "compositionend");
vi.runAllTimers();
});
expect(changes).toEqual(["你好"]);
});
});
@@ -0,0 +1,107 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { TextInput } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
interface WebTextInputElement extends TextInput {
value?: string;
setSelectionRange?: (start: number, end: number) => void;
addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void;
removeEventListener: (
type: "compositionstart" | "compositionend",
listener: EventListener,
) => void;
}
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
}));
export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>(
function ComposerTextInputWeb(
{ text, onChangeText, onPasteImages: _, onPasteError: __, ...props },
ref,
) {
const inputRef = useRef<TextInput | null>(null);
const textRef = useRef(text);
const onChangeTextRef = useRef(onChangeText);
const [isComposing, setIsComposing] = useState(false);
if (!isComposing) textRef.current = text;
onChangeTextRef.current = onChangeText;
useEffect(() => {
const input = inputRef.current as WebTextInputElement | null;
if (!input) return;
let compositionEndTimer: ReturnType<typeof setTimeout> | null = null;
const startComposition = () => {
if (compositionEndTimer !== null) {
clearTimeout(compositionEndTimer);
compositionEndTimer = null;
}
setIsComposing(true);
};
const endComposition = () => {
const nextText = input.value ?? "";
if (nextText !== textRef.current) {
textRef.current = nextText;
onChangeTextRef.current(nextText);
}
compositionEndTimer = setTimeout(() => {
compositionEndTimer = null;
setIsComposing(false);
}, 0);
};
input.addEventListener("compositionstart", startComposition);
input.addEventListener("compositionend", endComposition);
return () => {
if (compositionEndTimer !== null) clearTimeout(compositionEndTimer);
input.removeEventListener("compositionstart", startComposition);
input.removeEventListener("compositionend", endComposition);
};
}, []);
const handleChangeText = useCallback(
(nextText: string) => {
if (nextText === textRef.current) return;
textRef.current = nextText;
onChangeText(nextText);
},
[onChangeText],
);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) {
input.value = nextText;
}
if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end);
}
},
getNativeRef: () => inputRef.current,
}));
return (
<ThemedTextInput
{...props}
ref={inputRef}
value={isComposing ? undefined : text}
onChangeText={handleChangeText}
/>
);
},
);
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { normalizeNativePastedImages, UnsupportedPastedImageError } from "./native-pasted-image";
describe("normalizeNativePastedImages", () => {
it("turns pasted native image files into the existing picked-image input", () => {
expect(
normalizeNativePastedImages([
{
fileName: "clipboard.jpg",
fileSize: 128,
type: "image/jpg",
uri: "file:///cache/clipboard.jpg",
},
{
fileName: "animation.gif",
fileSize: 256,
type: "image/gif",
uri: "file:///cache/animation.gif",
},
]),
).toEqual([
{
source: { kind: "file_uri", uri: "file:///cache/clipboard.jpg" },
mimeType: "image/jpeg",
fileName: "clipboard.jpg",
},
{
source: { kind: "file_uri", uri: "file:///cache/animation.gif" },
mimeType: "image/gif",
fileName: "animation.gif",
},
]);
});
it("rejects non-image content instead of swallowing it as an attachment", () => {
expect(() =>
normalizeNativePastedImages([
{
fileName: "notes.txt",
fileSize: 12,
type: "text/plain",
uri: "file:///cache/notes.txt",
},
]),
).toThrow(UnsupportedPastedImageError);
});
});
@@ -0,0 +1,35 @@
import { resolveRasterImageMimeType } from "@/attachments/file-types";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
export interface NativePastedFile {
fileName: string;
fileSize: number;
type: string;
uri: string;
}
export class UnsupportedPastedImageError extends Error {
constructor(fileName: string) {
super(`Unsupported pasted image '${fileName}'.`);
this.name = "UnsupportedPastedImageError";
}
}
export function normalizeNativePastedImages(
files: readonly NativePastedFile[],
): PickedImageAttachmentInput[] {
return files.map((file) => {
const mimeType = resolveRasterImageMimeType({
mimeType: file.type,
path: file.fileName,
});
if (!mimeType) {
throw new UnsupportedPastedImageError(file.fileName);
}
return {
source: { kind: "file_uri", uri: file.uri },
mimeType,
fileName: file.fileName,
};
});
}
+46 -7
View File
@@ -19,7 +19,10 @@ import {
type TimelineReducerSideEffect,
} from "@/timeline/session-stream-reducers";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { isTimelineResumeSnapshotAuthoritative } from "@/timeline/timeline-sync-plan";
import {
isTimelineResumeSnapshotAuthoritative,
planTimelineTailFetch,
} from "@/timeline/timeline-sync-plan";
import {
createViewedTimelineSync,
type TimelineDeliveryMode,
@@ -33,6 +36,7 @@ import {
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
@@ -67,6 +71,20 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
type TimelineResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"];
function consumeForcedTimelineTailReplacement(
payload: TimelineResponsePayload,
replacements: Set<string>,
): TimelineResponsePayload {
if (payload.direction !== "tail") return payload;
if (!replacements.delete(payload.agentId)) return payload;
return { ...payload, reset: true };
}
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
export type {
@@ -350,6 +368,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
const forcedTimelineTailReplacements = useRef(new Set<string>());
const viewedTimelineSyncRef = useRef<ViewedTimelineSync | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
@@ -528,12 +547,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
const applyTimelineResponse = useCallback(
(
payload: Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"],
) => {
(receivedPayload: TimelineResponsePayload) => {
const payload = consumeForcedTimelineTailReplacement(
receivedPayload,
forcedTimelineTailReplacements.current,
);
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const shouldMarkAuthoritativeHistoryApplied = isTimelineResumeSnapshotAuthoritative({
@@ -642,6 +660,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const sync = createViewedTimelineSync({
initialDeliveryMode,
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
agentId,
);
return timeline.status === "synced" && timeline.range
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
: undefined;
},
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
@@ -672,6 +699,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
throw error;
}
},
fetchLatestTail: async (agentId) => {
forcedTimelineTailReplacements.current.add(agentId);
try {
return await getHostRuntimeStore().fetchAgentTimeline(
serverId,
agentId,
planTimelineTailFetch(),
);
} finally {
forcedTimelineTailReplacements.current.delete(agentId);
}
},
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
+6 -2
View File
@@ -4,6 +4,7 @@ import {
useQueries,
useQuery,
type QueryKey,
type QueryClient,
type UseQueryOptions,
type UseQueryResult,
} from "@tanstack/react-query";
@@ -47,8 +48,11 @@ export function useFetchQuery<
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(fetchQueryOptions(input));
>(
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(fetchQueryOptions(input), queryClient);
}
export function useFetchQueries<TData>(
@@ -0,0 +1,470 @@
import type { BrowserElementAttachment } from "@/attachments/types";
export type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
attributes?: Record<string, string>;
};
export type ElementSelectorMode = "annotate" | "screenshot";
export type ElementSelectorOutcome =
| { type: "selected"; mode: ElementSelectorMode; selection: BrowserElementSelection }
| { type: "cancelled" }
| { type: "failed"; reason: "loading" | "timeout" | "unavailable" };
export type ElementSelectorStartResult = "started" | "loading" | "unavailable";
interface ElementSelectorWebview extends HTMLElement {
isLoading?: () => boolean;
executeJavaScript?: (code: string) => Promise<unknown>;
}
interface ElementSelectorSession {
mode: ElementSelectorMode;
token: string;
webview: ElementSelectorWebview;
onFinish: (outcome: ElementSelectorOutcome) => void;
stopPolling?: () => void;
timeoutId?: number;
}
export interface ElementSelectorController {
start(input: {
webview: ElementSelectorWebview;
mode: ElementSelectorMode;
onFinish: (outcome: ElementSelectorOutcome) => void;
}): ElementSelectorStartResult;
cancel(): void;
stopForWebview(webview: ElementSelectorWebview): void;
}
const SELECTOR_TIMEOUT_MS = 30_000;
function executeWebviewJavaScript(webview: ElementSelectorWebview, code: string): Promise<unknown> {
if (!webview.isConnected) {
return Promise.resolve(null);
}
try {
return webview.executeJavaScript?.(code) ?? Promise.resolve(null);
} catch (error) {
return Promise.reject(error);
}
}
function ignoreWebviewJavaScriptError() {}
function destroyWebviewSelector(webview: ElementSelectorWebview, sessionToken: string): void {
const token = JSON.stringify(sessionToken);
void executeWebviewJavaScript(
webview,
`if (window.__paseoSelector?.sessionToken === ${token}) window.__paseoSelector.destroy();`,
).catch(ignoreWebviewJavaScriptError);
}
function clearWebviewSelector(webview: ElementSelectorWebview, sessionToken: string): void {
const token = JSON.stringify(sessionToken);
void executeWebviewJavaScript(
webview,
`if (window.__paseoSelector?.sessionToken === ${token}) window.__paseoSelector.destroy(); if (window.__paseoSelectorResult?.__paseoSessionToken === ${token}) window.__paseoSelectorResult = null;`,
).catch(ignoreWebviewJavaScriptError);
}
function readSelectorInstallation(
value: unknown,
sessionToken: string,
): "installed" | "loading" | "unavailable" {
if (value === null || typeof value !== "object") {
return "unavailable";
}
if (Reflect.get(value, "sessionToken") !== sessionToken) {
return "unavailable";
}
if (Reflect.get(value, "installed") === true) {
return "installed";
}
return Reflect.get(value, "reason") === "document-loading" ? "loading" : "unavailable";
}
function stopSessionTimers(session: ElementSelectorSession): void {
session.stopPolling?.();
session.stopPolling = undefined;
if (session.timeoutId !== undefined) {
window.clearTimeout(session.timeoutId);
session.timeoutId = undefined;
}
}
function startSelectorResultPolling(input: {
webview: ElementSelectorWebview;
sessionToken: string;
onResult: (selection: BrowserElementSelection | null) => void;
}): () => void {
const { webview, sessionToken, onResult } = input;
const token = JSON.stringify(sessionToken);
let stopped = false;
let timerId: number | undefined;
const schedule = () => {
if (!stopped) {
timerId = window.setTimeout(poll, 200);
}
};
const poll = () => {
void (async () => {
try {
const raw = await executeWebviewJavaScript(
webview,
`JSON.stringify(window.__paseoSelectorResult?.__paseoSessionToken === ${token} ? window.__paseoSelectorResult : null)`,
);
const result = typeof raw === "string" ? JSON.parse(raw) : null;
if (!result) {
schedule();
return;
}
stopped = true;
await executeWebviewJavaScript(
webview,
`if (window.__paseoSelectorResult?.__paseoSessionToken === ${token}) window.__paseoSelectorResult = null;`,
).catch(ignoreWebviewJavaScriptError);
const cancelled = result.__cancelled === true;
delete result.__cancelled;
delete result.__paseoSessionToken;
onResult(cancelled ? null : (result as BrowserElementSelection));
} catch {
schedule();
}
})();
};
schedule();
return () => {
stopped = true;
if (timerId !== undefined) {
window.clearTimeout(timerId);
}
};
}
function buildElementSelectorScript(sessionToken: string): string {
const token = JSON.stringify(sessionToken);
return `
(function() {
var sessionToken = ${token};
if (document.readyState === 'loading' || !document.head || !document.documentElement) {
return { installed: false, reason: 'document-loading', sessionToken: sessionToken };
}
if (window.__paseoSelector) { window.__paseoSelector.destroy(); }
window.__paseoSelectorResult = null;
var style = document.createElement('style');
style.textContent = [
'.__paseo-hover { outline: 2px solid #3b82f6 !important; outline-offset: 2px !important; cursor: crosshair !important; }',
'.__paseo-select-mode, .__paseo-select-mode * { cursor: crosshair !important; pointer-events: auto !important; user-select: none !important; }',
'.__paseo-select-mode *, .__paseo-select-mode *::before, .__paseo-select-mode *::after { animation: none !important; transition: none !important; }',
'.__paseo-select-mode a, .__paseo-select-mode button, .__paseo-select-mode input, .__paseo-select-mode select, .__paseo-select-mode textarea, .__paseo-select-mode [role="button"], .__paseo-select-mode [onclick] { pointer-events: none !important; }',
'.__paseo-select-mode iframe, .__paseo-select-mode video, .__paseo-select-mode audio { pointer-events: none !important; }',
'.__paseo-hover-label { position: fixed; z-index: 2147483647; pointer-events: none; max-width: 360px; padding: 4px 8px; border-radius: 6px; background: rgba(24,24,27,0.96); color: #fff; font: 500 11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; box-shadow: 0 2px 10px rgba(0,0,0,0.35); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'.__paseo-hover-label .__paseo-tag { color: #93c5fd; }',
'.__paseo-hover-label .__paseo-id { color: #fca5a5; }',
'.__paseo-hover-label .__paseo-cls { color: #fcd34d; }',
'.__paseo-hover-label .__paseo-dim { color: #a1a1aa; margin-left: 6px; }',
'.__paseo-hover-label .__paseo-comp { color: #86efac; margin-left: 6px; }',
].join('\\n');
document.head.appendChild(style);
document.documentElement.classList.add('__paseo-select-mode');
var hoverLabel = document.createElement('div');
hoverLabel.className = '__paseo-hover-label';
hoverLabel.style.display = 'none';
document.documentElement.appendChild(hoverLabel);
var last = null;
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, function(ch) {
return ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : ch === '>' ? '&gt;' : '&quot;';
});
}
function describeElement(el) {
var tag = el.tagName ? el.tagName.toLowerCase() : 'node';
var parts = ['<span class="__paseo-tag">' + escapeHtml(tag) + '</span>'];
if (el.id) {
parts.push('<span class="__paseo-id">#' + escapeHtml(el.id) + '</span>');
}
if (el.classList && el.classList.length) {
var cls = Array.prototype.slice.call(el.classList, 0, 2)
.filter(function(c) { return c.indexOf('__paseo') !== 0; })
.map(function(c) { return '.' + escapeHtml(c); })
.join('');
if (cls) parts.push('<span class="__paseo-cls">' + cls + '</span>');
}
var comp = getReactSource(el);
if (comp && comp.componentName) {
parts.push('<span class="__paseo-comp">&lt;' + escapeHtml(comp.componentName) + '&gt;</span>');
}
var rect = el.getBoundingClientRect();
parts.push('<span class="__paseo-dim">' + Math.round(rect.width) + '×' + Math.round(rect.height) + '</span>');
return { html: parts.join(''), rect: rect };
}
function positionLabel(rect, e) {
var lw = hoverLabel.offsetWidth || 0;
var lh = hoverLabel.offsetHeight || 0;
var top = rect.top - lh - 6;
if (top < 4) top = rect.bottom + 6;
if (top + lh > window.innerHeight - 4) top = Math.max(4, e.clientY - lh - 6);
var left = rect.left;
if (left + lw > window.innerWidth - 4) left = Math.max(4, window.innerWidth - lw - 4);
if (left < 4) left = 4;
hoverLabel.style.top = Math.round(top) + 'px';
hoverLabel.style.left = Math.round(left) + 'px';
}
function onMove(e) {
e.preventDefault();
e.stopPropagation();
if (last) last.classList.remove('__paseo-hover');
var el = e.target;
el.classList.add('__paseo-hover');
last = el;
try {
var info = describeElement(el);
hoverLabel.innerHTML = info.html;
hoverLabel.style.display = 'block';
positionLabel(info.rect, e);
} catch (err) {
hoverLabel.style.display = 'none';
}
}
function buildSelector(el) {
if (el.id) return '#' + el.id;
var path = [];
while (el && el.nodeType === 1) {
var seg = el.tagName.toLowerCase();
if (el.id) { path.unshift('#' + el.id); break; }
var sib = el, nth = 1;
while (sib = sib.previousElementSibling) { if (sib.tagName === el.tagName) nth++; }
if (nth > 1) seg += ':nth-of-type(' + nth + ')';
path.unshift(seg);
el = el.parentElement;
}
return path.join(' > ');
}
function getReactSource(el) {
var keys = Object.keys(el);
for (var i = 0; i < keys.length; i++) {
if (keys[i].startsWith('__reactFiber$') || keys[i].startsWith('__reactInternalInstance$')) {
var fiber = el[keys[i]];
while (fiber) {
if (fiber._debugSource) {
return {
fileName: fiber._debugSource.fileName || null,
lineNumber: fiber._debugSource.lineNumber || null,
columnNumber: fiber._debugSource.columnNumber || null,
componentName: (fiber.type && (typeof fiber.type === 'string' ? fiber.type : fiber.type.displayName || fiber.type.name)) || null
};
}
if (fiber._debugOwner) { fiber = fiber._debugOwner; }
else if (fiber.return) { fiber = fiber.return; }
else break;
}
}
}
return null;
}
function getParentChain(el, depth) {
var chain = [];
var cur = el.parentElement;
for (var i = 0; i < (depth || 5) && cur; i++) {
var desc = cur.tagName.toLowerCase();
if (cur.id) desc += '#' + cur.id;
if (cur.className && typeof cur.className === 'string') { var cls = cur.className.trim().replace(/ +/g, ' ').split(' ').slice(0,2).join('.'); if (cls) desc += '.' + cls; }
chain.push(desc);
cur = cur.parentElement;
}
return chain;
}
function getChildSummary(el, max) {
var kids = [];
for (var i = 0; i < Math.min(el.children.length, max || 8); i++) {
var c = el.children[i];
var desc = c.tagName.toLowerCase();
if (c.id) desc += '#' + c.id;
kids.push(desc);
}
if (el.children.length > (max || 8)) kids.push('...(' + el.children.length + ' total)');
return kids;
}
function getRelevantStyles(el) {
var cs = window.getComputedStyle(el);
var pick = ['display','position','width','height','color','background-color','font-size','font-family','padding','margin','border','flex','grid-template-columns','gap','overflow','opacity','z-index'];
var out = {};
pick.forEach(function(p) {
var v = cs.getPropertyValue(p);
if (v && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') out[p] = v;
});
return out;
}
function onClick(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
var el = e.target;
if (last) last.classList.remove('__paseo-hover');
hoverLabel.style.display = 'none';
var attrs = {};
for (var i = 0; i < el.attributes.length; i++) {
attrs[el.attributes[i].name] = el.attributes[i].value;
}
var rect = el.getBoundingClientRect();
var result = {
tag: el.tagName.toLowerCase(),
text: (el.innerText || '').substring(0, 500),
selector: buildSelector(el),
attributes: attrs,
url: location.href,
outerHTML: el.outerHTML.substring(0, 2000),
computedStyles: getRelevantStyles(el),
__paseoSessionToken: sessionToken,
boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
reactSource: getReactSource(el),
parentChain: getParentChain(el, 5),
children: getChildSummary(el, 8)
};
destroy();
window.__paseoSelectorResult = result;
}
function onKey(e) {
if (e.key === 'Escape') {
destroy();
window.__paseoSelectorResult = { __cancelled: true, __paseoSessionToken: sessionToken };
}
}
function blockEvent(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
function destroy() {
document.removeEventListener('mousemove', onMove, true);
document.removeEventListener('click', onClick, true);
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('mousedown', blockEvent, true);
document.removeEventListener('mouseup', blockEvent, true);
document.removeEventListener('pointerdown', blockEvent, true);
document.removeEventListener('pointerup', blockEvent, true);
document.removeEventListener('touchstart', blockEvent, true);
document.removeEventListener('touchend', blockEvent, true);
document.removeEventListener('focus', blockEvent, true);
document.removeEventListener('submit', blockEvent, true);
document.documentElement.classList.remove('__paseo-select-mode');
if (last) last.classList.remove('__paseo-hover');
if (hoverLabel.parentNode) hoverLabel.parentNode.removeChild(hoverLabel);
style.remove();
window.__paseoSelector = null;
}
document.addEventListener('mousemove', onMove, true);
document.addEventListener('click', onClick, true);
document.addEventListener('keydown', onKey, true);
document.addEventListener('mousedown', blockEvent, true);
document.addEventListener('mouseup', blockEvent, true);
document.addEventListener('pointerdown', blockEvent, true);
document.addEventListener('pointerup', blockEvent, true);
document.addEventListener('touchstart', blockEvent, true);
document.addEventListener('touchend', blockEvent, true);
document.addEventListener('focus', blockEvent, true);
document.addEventListener('submit', blockEvent, true);
window.__paseoSelector = { destroy: destroy, sessionToken: sessionToken };
return { installed: true, sessionToken: sessionToken };
})()
`;
}
export function createElementSelectorController(): ElementSelectorController {
let currentSession: ElementSelectorSession | null = null;
let sessionCounter = 0;
const finish = (
session: ElementSelectorSession,
outcome: ElementSelectorOutcome,
guestCleanup: "clear" | "destroy" | null,
): boolean => {
stopSessionTimers(session);
if (currentSession !== session) {
return false;
}
currentSession = null;
if (guestCleanup === "clear") {
clearWebviewSelector(session.webview, session.token);
} else if (guestCleanup === "destroy") {
destroyWebviewSelector(session.webview, session.token);
}
session.onFinish(outcome);
return true;
};
return {
start({ webview, mode, onFinish }) {
if (!webview.isConnected) {
return "unavailable";
}
if (webview.isLoading?.()) {
return "loading";
}
if (currentSession) {
finish(currentSession, { type: "cancelled" }, "clear");
}
sessionCounter += 1;
const session: ElementSelectorSession = {
mode,
token: `${sessionCounter}:${crypto.randomUUID()}`,
webview,
onFinish,
};
currentSession = session;
session.timeoutId = window.setTimeout(() => {
finish(session, { type: "failed", reason: "timeout" }, "destroy");
}, SELECTOR_TIMEOUT_MS);
void executeWebviewJavaScript(webview, buildElementSelectorScript(session.token))
.then((installation) => {
const installationState = readSelectorInstallation(installation, session.token);
if (currentSession !== session) {
if (installationState === "installed") {
destroyWebviewSelector(webview, session.token);
}
return undefined;
}
if (installationState !== "installed") {
finish(session, { type: "failed", reason: installationState }, null);
return undefined;
}
session.stopPolling = startSelectorResultPolling({
webview,
sessionToken: session.token,
onResult: (selection) => {
finish(
session,
selection
? { type: "selected", mode: session.mode, selection }
: { type: "cancelled" },
null,
);
},
});
return undefined;
})
.catch(() => {
finish(session, { type: "failed", reason: "unavailable" }, "destroy");
return undefined;
});
return "started";
},
cancel() {
if (currentSession) {
finish(currentSession, { type: "cancelled" }, "clear");
}
},
stopForWebview(webview) {
if (currentSession?.webview === webview) {
finish(currentSession, { type: "cancelled" }, "destroy");
}
},
};
}
@@ -59,7 +59,6 @@ import {
} from "@/desktop/browser/store";
import {
applyInactiveBrowserWebviewViewport,
isResidentBrowserWebviewReady,
prepareBrowserWebview,
presentBrowserWebview,
rememberBrowserWebviewSize,
@@ -67,6 +66,11 @@ import {
removeResidentBrowserWebview,
takeResidentBrowserWebview,
} from "../resident-webviews";
import {
createElementSelectorController,
type BrowserElementSelection,
type ElementSelectorOutcome,
} from "./element-selector.electron";
type ElectronWebview = HTMLElement & {
canGoBack?: () => boolean;
@@ -77,6 +81,7 @@ type ElectronWebview = HTMLElement & {
stop?: () => void;
loadURL?: (url: string) => Promise<void>;
getURL?: () => string;
isLoading?: () => boolean;
executeJavaScript?: (code: string) => Promise<unknown>;
focus?: () => void;
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
@@ -87,10 +92,6 @@ type WebTextInput = TextInput & {
getNativeRef?: () => unknown;
};
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
attributes?: Record<string, string>;
};
interface BrowserElementAnnotation {
comment: string;
}
@@ -315,20 +316,6 @@ function executeWebviewJavaScript(webview: ElectronWebview, code: string): Promi
function ignoreWebviewJavaScriptError() {}
function destroyWebviewSelector(webview: ElectronWebview): void {
void executeWebviewJavaScript(
webview,
"if(window.__paseoSelector) window.__paseoSelector.destroy();",
).catch(ignoreWebviewJavaScriptError);
}
function clearWebviewSelector(webview: ElectronWebview): void {
void executeWebviewJavaScript(
webview,
"if(window.__paseoSelector) window.__paseoSelector.destroy(); window.__paseoSelectorResult = null;",
).catch(ignoreWebviewJavaScriptError);
}
interface BrowserAnnotationMarker {
index: number;
selector: string;
@@ -437,38 +424,6 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow
return event.action === "focus-url";
}
function startSelectorResultPolling(input: {
webview: ElectronWebview;
onSelection: (selection: BrowserElementSelection) => void;
onDone: () => void;
}): number {
const { webview, onSelection, onDone } = input;
const poll = window.setInterval(() => {
void (async () => {
try {
const raw = await executeWebviewJavaScript(
webview,
"JSON.stringify(window.__paseoSelectorResult || null)",
);
const result = typeof raw === "string" ? JSON.parse(raw) : null;
if (!result) {
return;
}
window.clearInterval(poll);
onDone();
await executeWebviewJavaScript(webview, "window.__paseoSelectorResult = null;");
if (!result.__cancelled) {
onSelection(result as BrowserElementSelection);
}
} catch {
// Keep polling; cross-origin/webview timing can make this transient.
}
})();
}, 200);
return poll;
}
function ToolbarButton({
label,
children,
@@ -655,13 +610,15 @@ export function BrowserPane({
const browserRef = useRef(browser);
browserRef.current = browser;
const pendingNavigationUrlRef = useRef<string | null>(null);
const domReadyRef = useRef(false);
const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]);
const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null);
const selectorControllerRef = useRef<ReturnType<typeof createElementSelectorController> | null>(
null,
);
if (!selectorControllerRef.current) {
selectorControllerRef.current = createElementSelectorController();
}
const selectorActive = selectorMode !== null;
// Which action the active selector performs on click: open the annotation card
// ("annotate") or copy a screenshot of the element to the clipboard ("screenshot").
const selectorModeRef = useRef<"annotate" | "screenshot">("annotate");
const toast = useToast();
const toastRef = useRef(toast);
toastRef.current = toast;
@@ -737,7 +694,7 @@ export function BrowserPane({
const syncNavigationState = useCallback((input?: { syncUrl?: boolean }) => {
const webview = webviewRef.current;
if (!webview || !domReadyRef.current) {
if (!webview) {
return;
}
@@ -776,7 +733,6 @@ export function BrowserPane({
const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null;
const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview);
webviewRef.current = webview;
domReadyRef.current = isResidentBrowserWebviewReady(webview);
if (!residentWebview) {
prepareBrowserWebview(webview, {
browserId,
@@ -808,7 +764,7 @@ export function BrowserPane({
});
const handleStartLoading = () => {
domReadyRef.current = false;
selectorControllerRef.current?.stopForWebview(webview);
updateBrowser(browserId, { isLoading: true, lastError: null });
syncNavigationState({ syncUrl: false });
};
@@ -875,7 +831,6 @@ export function BrowserPane({
});
};
const handleDomReady = () => {
domReadyRef.current = true;
syncNavigationState();
// The previous page's overlay is gone after a load; re-apply markers for
// the freshly loaded document.
@@ -940,10 +895,10 @@ export function BrowserPane({
} else {
removeResidentBrowserWebview(browserIdRef.current);
}
selectorControllerRef.current?.stopForWebview(webview);
if (webviewRef.current === webview) {
webviewRef.current = null;
}
domReadyRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [browserId, onFocusPane]);
@@ -1196,8 +1151,8 @@ export function BrowserPane({
);
const handleSelectorResult = useCallback(
(selection: BrowserElementSelection) => {
if (selectorModeRef.current === "screenshot") {
(selection: BrowserElementSelection, mode: "annotate" | "screenshot") => {
if (mode === "screenshot") {
void screenshotElementToClipboard(selection);
return;
}
@@ -1230,271 +1185,62 @@ export function BrowserPane({
setPendingSelection(null);
}, []);
const showSelectorFailure = useCallback(
(reason: "loading" | "timeout" | "unavailable") => {
const message =
reason === "loading"
? t("workspace.browser.controls.selectorLoading")
: t("workspace.browser.controls.selectorFailed");
toastRef.current?.error(message);
},
[t],
);
const handleElementSelectorOutcome = useCallback(
(outcome: ElementSelectorOutcome) => {
setSelectorMode(null);
if (outcome.type === "selected") {
handleSelectorResult(outcome.selection, outcome.mode);
} else if (outcome.type === "failed") {
showSelectorFailure(outcome.reason);
}
},
[handleSelectorResult, showSelectorFailure],
);
const startElementSelector = useCallback(
(mode: "annotate" | "screenshot") => {
if (mode === "annotate" && !workspaceAttachmentScopeKey) {
showSelectorFailure("unavailable");
return;
}
const webview = webviewRef.current;
if (!webview || !domReadyRef.current) return;
// Annotate needs a workspace scope to attach to; screenshot only copies.
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
selectorModeRef.current = mode;
const controller = selectorControllerRef.current;
if (!webview || !controller) {
showSelectorFailure("unavailable");
return;
}
const startResult = controller.start({
webview,
mode,
onFinish: handleElementSelectorOutcome,
});
if (startResult !== "started") {
showSelectorFailure(startResult);
return;
}
pendingScreenshotRef.current = undefined;
setPendingSelection(null);
setSelectorMode(mode);
const js = `
(function() {
if (window.__paseoSelector) { window.__paseoSelector.destroy(); }
var overlay = null;
var style = document.createElement('style');
style.textContent = [
'.__paseo-hover { outline: 2px solid #3b82f6 !important; outline-offset: 2px !important; cursor: crosshair !important; }',
'.__paseo-select-mode, .__paseo-select-mode * { cursor: crosshair !important; pointer-events: auto !important; user-select: none !important; }',
'.__paseo-select-mode *, .__paseo-select-mode *::before, .__paseo-select-mode *::after { animation: none !important; transition: none !important; }',
'.__paseo-select-mode a, .__paseo-select-mode button, .__paseo-select-mode input, .__paseo-select-mode select, .__paseo-select-mode textarea, .__paseo-select-mode [role="button"], .__paseo-select-mode [onclick] { pointer-events: none !important; }',
'.__paseo-select-mode iframe, .__paseo-select-mode video, .__paseo-select-mode audio { pointer-events: none !important; }',
'.__paseo-hover-label { position: fixed; z-index: 2147483647; pointer-events: none; max-width: 360px; padding: 4px 8px; border-radius: 6px; background: rgba(24,24,27,0.96); color: #fff; font: 500 11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; box-shadow: 0 2px 10px rgba(0,0,0,0.35); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'.__paseo-hover-label .__paseo-tag { color: #93c5fd; }',
'.__paseo-hover-label .__paseo-id { color: #fca5a5; }',
'.__paseo-hover-label .__paseo-cls { color: #fcd34d; }',
'.__paseo-hover-label .__paseo-dim { color: #a1a1aa; margin-left: 6px; }',
'.__paseo-hover-label .__paseo-comp { color: #86efac; margin-left: 6px; }',
].join('\\n');
document.head.appendChild(style);
document.documentElement.classList.add('__paseo-select-mode');
var hoverLabel = document.createElement('div');
hoverLabel.className = '__paseo-hover-label';
hoverLabel.style.display = 'none';
document.documentElement.appendChild(hoverLabel);
var last = null;
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, function(ch) {
return ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : ch === '>' ? '&gt;' : '&quot;';
});
}
function describeElement(el) {
var tag = el.tagName ? el.tagName.toLowerCase() : 'node';
var parts = ['<span class="__paseo-tag">' + escapeHtml(tag) + '</span>'];
if (el.id) {
parts.push('<span class="__paseo-id">#' + escapeHtml(el.id) + '</span>');
}
if (el.classList && el.classList.length) {
var cls = Array.prototype.slice.call(el.classList, 0, 2)
.filter(function(c) { return c.indexOf('__paseo') !== 0; })
.map(function(c) { return '.' + escapeHtml(c); })
.join('');
if (cls) parts.push('<span class="__paseo-cls">' + cls + '</span>');
}
var comp = getReactSource(el);
if (comp && comp.componentName) {
parts.push('<span class="__paseo-comp">&lt;' + escapeHtml(comp.componentName) + '&gt;</span>');
}
var rect = el.getBoundingClientRect();
parts.push('<span class="__paseo-dim">' + Math.round(rect.width) + '×' + Math.round(rect.height) + '</span>');
return { html: parts.join(''), rect: rect };
}
function positionLabel(rect, e) {
var pad = 12;
var lw = hoverLabel.offsetWidth || 0;
var lh = hoverLabel.offsetHeight || 0;
var top = rect.top - lh - 6;
if (top < 4) top = rect.bottom + 6;
if (top + lh > window.innerHeight - 4) top = Math.max(4, e.clientY - lh - 6);
var left = rect.left;
if (left + lw > window.innerWidth - 4) left = Math.max(4, window.innerWidth - lw - 4);
if (left < 4) left = 4;
hoverLabel.style.top = Math.round(top) + 'px';
hoverLabel.style.left = Math.round(left) + 'px';
}
function onMove(e) {
e.preventDefault();
e.stopPropagation();
if (last) last.classList.remove('__paseo-hover');
var el = e.target;
el.classList.add('__paseo-hover');
last = el;
try {
var info = describeElement(el);
hoverLabel.innerHTML = info.html;
hoverLabel.style.display = 'block';
positionLabel(info.rect, e);
} catch (err) {
hoverLabel.style.display = 'none';
}
}
function buildSelector(el) {
if (el.id) return '#' + el.id;
var path = [];
while (el && el.nodeType === 1) {
var seg = el.tagName.toLowerCase();
if (el.id) { path.unshift('#' + el.id); break; }
var sib = el, nth = 1;
while (sib = sib.previousElementSibling) { if (sib.tagName === el.tagName) nth++; }
if (nth > 1) seg += ':nth-of-type(' + nth + ')';
path.unshift(seg);
el = el.parentElement;
}
return path.join(' > ');
}
function getReactSource(el) {
var keys = Object.keys(el);
for (var i = 0; i < keys.length; i++) {
if (keys[i].startsWith('__reactFiber$') || keys[i].startsWith('__reactInternalInstance$')) {
var fiber = el[keys[i]];
while (fiber) {
if (fiber._debugSource) {
return {
fileName: fiber._debugSource.fileName || null,
lineNumber: fiber._debugSource.lineNumber || null,
columnNumber: fiber._debugSource.columnNumber || null,
componentName: (fiber.type && (typeof fiber.type === 'string' ? fiber.type : fiber.type.displayName || fiber.type.name)) || null
};
}
if (fiber._debugOwner) { fiber = fiber._debugOwner; }
else if (fiber.return) { fiber = fiber.return; }
else break;
}
}
}
return null;
}
function getParentChain(el, depth) {
var chain = [];
var cur = el.parentElement;
for (var i = 0; i < (depth || 5) && cur; i++) {
var desc = cur.tagName.toLowerCase();
if (cur.id) desc += '#' + cur.id;
if (cur.className && typeof cur.className === 'string') { var cls = cur.className.trim().replace(/ +/g, ' ').split(' ').slice(0,2).join('.'); if (cls) desc += '.' + cls; }
chain.push(desc);
cur = cur.parentElement;
}
return chain;
}
function getChildSummary(el, max) {
var kids = [];
for (var i = 0; i < Math.min(el.children.length, max || 8); i++) {
var c = el.children[i];
var desc = c.tagName.toLowerCase();
if (c.id) desc += '#' + c.id;
kids.push(desc);
}
if (el.children.length > (max || 8)) kids.push('...(' + el.children.length + ' total)');
return kids;
}
function getRelevantStyles(el) {
var cs = window.getComputedStyle(el);
var pick = ['display','position','width','height','color','background-color','font-size','font-family','padding','margin','border','flex','grid-template-columns','gap','overflow','opacity','z-index'];
var out = {};
pick.forEach(function(p) {
var v = cs.getPropertyValue(p);
if (v && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') out[p] = v;
});
return out;
}
function onClick(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
var el = e.target;
if (last) last.classList.remove('__paseo-hover');
hoverLabel.style.display = 'none';
var attrs = {};
for (var i = 0; i < el.attributes.length; i++) {
attrs[el.attributes[i].name] = el.attributes[i].value;
}
var rect = el.getBoundingClientRect();
var result = {
tag: el.tagName.toLowerCase(),
text: (el.innerText || '').substring(0, 500),
selector: buildSelector(el),
attributes: attrs,
url: location.href,
outerHTML: el.outerHTML.substring(0, 2000),
computedStyles: getRelevantStyles(el),
boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
reactSource: getReactSource(el),
parentChain: getParentChain(el, 5),
children: getChildSummary(el, 8)
};
destroy();
window.__paseoSelectorResult = result;
}
function onKey(e) {
if (e.key === 'Escape') { destroy(); window.__paseoSelectorResult = { __cancelled: true }; }
}
function blockEvent(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
function destroy() {
document.removeEventListener('mousemove', onMove, true);
document.removeEventListener('click', onClick, true);
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('mousedown', blockEvent, true);
document.removeEventListener('mouseup', blockEvent, true);
document.removeEventListener('pointerdown', blockEvent, true);
document.removeEventListener('pointerup', blockEvent, true);
document.removeEventListener('touchstart', blockEvent, true);
document.removeEventListener('touchend', blockEvent, true);
document.removeEventListener('focus', blockEvent, true);
document.removeEventListener('submit', blockEvent, true);
document.documentElement.classList.remove('__paseo-select-mode');
if (last) last.classList.remove('__paseo-hover');
if (hoverLabel.parentNode) hoverLabel.parentNode.removeChild(hoverLabel);
style.remove();
window.__paseoSelector = null;
}
document.addEventListener('mousemove', onMove, true);
document.addEventListener('click', onClick, true);
document.addEventListener('keydown', onKey, true);
document.addEventListener('mousedown', blockEvent, true);
document.addEventListener('mouseup', blockEvent, true);
document.addEventListener('pointerdown', blockEvent, true);
document.addEventListener('pointerup', blockEvent, true);
document.addEventListener('touchstart', blockEvent, true);
document.addEventListener('touchend', blockEvent, true);
document.addEventListener('focus', blockEvent, true);
document.addEventListener('submit', blockEvent, true);
window.__paseoSelector = { destroy: destroy };
})()
`;
try {
void executeWebviewJavaScript(webview, js)
.then(() => {
const poll = startSelectorResultPolling({
webview,
onSelection: handleSelectorResult,
onDone: () => setSelectorMode(null),
});
window.setTimeout(() => {
window.clearInterval(poll);
setSelectorMode(null);
if (webviewRef.current !== webview || !domReadyRef.current) {
return;
}
destroyWebviewSelector(webview);
}, 30000);
return undefined;
})
.catch(() => {
setSelectorMode(null);
});
} catch {
setSelectorMode(null);
}
},
[handleSelectorResult, workspaceAttachmentScopeKey],
[handleElementSelectorOutcome, showSelectorFailure, workspaceAttachmentScopeKey],
);
const cancelElementSelector = useCallback(() => {
const webview = webviewRef.current;
selectorControllerRef.current?.cancel();
setSelectorMode(null);
if (webview && domReadyRef.current) {
try {
clearWebviewSelector(webview);
} catch {}
}
}, []);
const currentPageUrl = browser?.url ?? null;
@@ -1526,7 +1272,7 @@ export function BrowserPane({
return;
}
const webview = webviewRef.current;
if (!webview || !domReadyRef.current) {
if (!webview) {
return;
}
if (annotationMarkers.length === 0) {
@@ -5,7 +5,6 @@ import {
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
getResidentBrowserWebview,
isResidentBrowserWebviewReady,
prepareBrowserWebview,
presentBrowserWebview,
rememberBrowserWebviewSize,
@@ -205,28 +204,6 @@ describe("resident browser webviews", () => {
expect(webview.parentElement.style.height).toBe("600px");
});
it("retains document readiness with the resident webview", () => {
const webview = ensureTestBrowser({
browserId: "browser-ready",
workspaceId: "workspace-ready",
url: "https://example.com",
});
if (!webview) {
throw new Error("Expected resident browser webview");
}
expect(isResidentBrowserWebviewReady(webview)).toBe(false);
webview.dispatchEvent(new Event("dom-ready"));
releaseResidentBrowserWebview("browser-ready", webview);
const reusedWebview = takeResidentBrowserWebview("browser-ready");
expect(reusedWebview).toBe(webview);
expect(isResidentBrowserWebviewReady(reusedWebview as HTMLElement)).toBe(true);
webview.dispatchEvent(new Event("did-start-loading"));
expect(isResidentBrowserWebviewReady(webview)).toBe(false);
});
it("creates a resident webview for an agent-created unfocused tab", () => {
const webview = ensureTestBrowser({
browserId: "browser-agent",
@@ -15,7 +15,6 @@ const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
const residentSurfacesByBrowserId = new Map<string, HTMLElement>();
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
const readyResidentWebviews = new WeakSet<HTMLElement>();
interface BrowserWebviewElement extends HTMLElement {
src: string;
@@ -75,15 +74,6 @@ function registerBrowserWhenAttached(
});
}
function registerBrowserReadiness(webview: HTMLElement): void {
webview.addEventListener("did-start-loading", () => {
readyResidentWebviews.delete(webview);
});
webview.addEventListener("dom-ready", () => {
readyResidentWebviews.add(webview);
});
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -322,11 +312,6 @@ export function prepareBrowserWebview(
(webview as BrowserWebviewElement).src = input.initialUrl;
}
registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser);
registerBrowserReadiness(webview);
}
export function isResidentBrowserWebviewReady(webview: HTMLElement): boolean {
return readyResidentWebviews.has(webview);
}
export function ensureResidentBrowserWebview(input: {
+212 -57
View File
@@ -4,6 +4,7 @@ import {
useEffect,
useMemo,
useRef,
useSyncExternalStore,
memo,
type ReactElement,
type ReactNode,
@@ -43,6 +44,12 @@ import {
} from "lucide-react-native";
import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import {
createDiffRowWindow,
createDiffViewport,
type DiffRowWindow,
type DiffViewport,
} from "@/git/diff-virtualization";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import {
@@ -104,6 +111,7 @@ import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layo
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
compactHighlightTokens,
formatDiffContentText,
formatDiffGutterText,
hasVisibleDiffTokens,
@@ -180,7 +188,7 @@ function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
function HighlightedText({
const HighlightedText = memo(function HighlightedText({
tokens,
textMetricsStyle,
wrapLines = false,
@@ -196,10 +204,14 @@ function HighlightedText({
[textMetricsStyle, wrapLines],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
const keyedTokens = useMemo(() => {
let characterOffset = 0;
return compactHighlightTokens(tokens).map((token) => {
const keyedToken = { key: `${characterOffset}-${token.style ?? "base"}`, token };
characterOffset += token.text.length;
return keyedToken;
});
}, [tokens]);
return (
<Text style={containerStyle} testID={testID}>
@@ -208,7 +220,7 @@ function HighlightedText({
))}
</Text>
);
}
});
interface DiffFileSectionProps {
file: ParsedDiffFile;
@@ -804,8 +816,53 @@ function InlineReviewRow({
);
}
const DIFF_ROW_OVERSCAN = 900;
const FULL_DIFF_VIEWPORT = { top: 0, height: Number.MAX_SAFE_INTEGER };
const subscribeToNothing = () => () => {};
const getFullDiffViewport = () => FULL_DIFF_VIEWPORT;
function useDiffRowWindow({
viewport,
bodyOffset,
rowHeights,
enabled,
}: {
viewport?: DiffViewport;
bodyOffset: number;
rowHeights: readonly number[];
enabled: boolean;
}): DiffRowWindow {
const viewportSnapshot = useSyncExternalStore(
viewport?.subscribe ?? subscribeToNothing,
viewport?.getSnapshot ?? getFullDiffViewport,
viewport?.getSnapshot ?? getFullDiffViewport,
);
return useMemo(() => {
if (!enabled) {
return createDiffRowWindow(rowHeights, {
viewportTop: 0,
viewportHeight: Number.MAX_SAFE_INTEGER,
overscan: 0,
});
}
return createDiffRowWindow(rowHeights, {
viewportTop: viewportSnapshot.top - bodyOffset - BORDER_WIDTH[1],
viewportHeight: viewportSnapshot.height,
overscan: DIFF_ROW_OVERSCAN,
});
}, [bodyOffset, enabled, rowHeights, viewportSnapshot.height, viewportSnapshot.top]);
}
function DiffRowSpacer({ height }: { height: number }) {
if (height <= 0) {
return null;
}
return <View style={inlineUnistylesStyle({ height })} />;
}
function SplitDiffColumn({
rows,
rowWindow,
side,
gutterWidth,
wrapLines,
@@ -814,6 +871,7 @@ function SplitDiffColumn({
showDivider = false,
}: {
rows: SplitDiffRow[];
rowWindow: DiffRowWindow;
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
@@ -844,12 +902,20 @@ function SplitDiffColumn({
[textMetricsStyle],
);
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
const keyedRows = useMemo(
() =>
rows.slice(rowWindow.startIndex, rowWindow.endIndex).map((row, index) => ({
key: `row-${rowWindow.startIndex + index}`,
row,
})),
[rowWindow.endIndex, rowWindow.startIndex, rows],
);
if (wrapLines) {
return (
<View style={wrapCellStyle}>
<View style={styles.linesContainer}>
<DiffRowSpacer height={rowWindow.beforeHeight} />
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
@@ -882,6 +948,7 @@ function SplitDiffColumn({
</View>
);
})}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</View>
);
@@ -890,6 +957,7 @@ function SplitDiffColumn({
return (
<View style={rowCellStyle}>
<View style={styles.gutterColumn}>
<DiffRowSpacer height={rowWindow.beforeHeight} />
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
@@ -931,6 +999,7 @@ function SplitDiffColumn({
</View>
);
})}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
<DiffScroll
scrollViewWidth={scrollWidth}
@@ -939,6 +1008,7 @@ function SplitDiffColumn({
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
<DiffRowSpacer height={rowWindow.beforeHeight} />
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
@@ -974,6 +1044,7 @@ function SplitDiffColumn({
</View>
);
})}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</DiffScroll>
</View>
@@ -1255,6 +1326,8 @@ const DiffFileHeader = memo(function DiffFileHeader({
export function DiffFileBody({
file,
viewport,
bodyOffset = 0,
layout,
wrapLines,
codeFontSize,
@@ -1264,6 +1337,8 @@ export function DiffFileBody({
testID,
}: {
file: ParsedDiffFile;
viewport?: DiffViewport;
bodyOffset?: number;
layout: "unified" | "split";
wrapLines: boolean;
codeFontSize: number;
@@ -1276,6 +1351,50 @@ export function DiffFileBody({
const [bodyWidth, setBodyWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
const { t } = useTranslation();
const canRenderRows = file.status !== "too_large" && file.status !== "binary";
const splitRows = useMemo(
() => (canRenderRows && layout === "split" ? getCachedSplitDiffRows(file) : []),
[canRenderRows, file, layout],
);
const unifiedLines = useMemo(
() => (canRenderRows && layout === "unified" ? getCachedUnifiedDiffLines(file) : []),
[canRenderRows, file, layout],
);
const rowHeights = useMemo(() => {
const lineHeight = getNumericLineHeight(textMetricsStyle) ?? Math.round(codeFontSize * 1.5);
if (layout === "split") {
return splitRows.map((row) => {
if (row.kind === "header") {
return lineHeight;
}
return (
lineHeight +
(getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
})?.height ?? 0)
);
});
}
return unifiedLines.map(
({ reviewTarget }) =>
lineHeight + (getInlineReviewThreadState({ reviewTarget, reviewActions })?.height ?? 0),
);
}, [codeFontSize, layout, reviewActions, splitRows, textMetricsStyle, unifiedLines]);
const rowWindow = useDiffRowWindow({
viewport,
bodyOffset,
rowHeights,
enabled: Boolean(viewport) && canRenderRows && !wrapLines,
});
const visibleUnifiedLines = useMemo(
() =>
unifiedLines
.slice(rowWindow.startIndex, rowWindow.endIndex)
.map((entry, index) => ({ entry, index: rowWindow.startIndex + index })),
[rowWindow.endIndex, rowWindow.startIndex, unifiedLines],
);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
@@ -1324,11 +1443,11 @@ export function DiffFileBody({
const gutterWidth = lineNumberGutterWidth(maxLineNo, codeFontSize);
if (layout === "split") {
const rows = buildSplitDiffRows(file);
return (
<View style={[styles.diffContent, styles.splitRow]} dataSet={CODE_SURFACE_DATASET}>
<SplitDiffColumn
rows={rows}
rows={splitRows}
rowWindow={rowWindow}
side="left"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
@@ -1336,7 +1455,8 @@ export function DiffFileBody({
reviewActions={reviewActions}
/>
<SplitDiffColumn
rows={rows}
rows={splitRows}
rowWindow={rowWindow}
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
@@ -1348,30 +1468,32 @@ export function DiffFileBody({
);
}
const computedLines = buildUnifiedDiffLines(file);
if (wrapLines) {
return (
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key} testID={`diff-wrapped-row-${index}`}>
<DiffLineView
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
<DiffRowSpacer height={rowWindow.beforeHeight} />
{visibleUnifiedLines.map(
({ entry: { line, lineNumber, key, reviewTarget }, index }) => (
<View key={key} testID={`diff-wrapped-row-${index}`}>
<DiffLineView
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
),
)}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</View>
);
@@ -1382,28 +1504,33 @@ export function DiffFileBody({
return (
<View style={[styles.diffContent, styles.diffContentRow]} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key} testID={`diff-gutter-row-${index}`}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
}
textTestID={`diff-gutter-text-${index}`}
actionTestID={`diff-gutter-action-${index}`}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
<DiffRowSpacer height={rowWindow.beforeHeight} />
{visibleUnifiedLines.map(
({ entry: { line, lineNumber, key, reviewTarget }, index }) => (
<View key={key} testID={`diff-gutter-row-${index}`}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
reviewTarget?.key !== undefined &&
hoveredReviewTargetKey === reviewTarget.key
}
textTestID={`diff-gutter-text-${index}`}
actionTestID={`diff-gutter-action-${index}`}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
),
)}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
<DiffScroll
scrollViewWidth={scrollViewWidth}
@@ -1412,7 +1539,8 @@ export function DiffFileBody({
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
{computedLines.map(({ line, key, reviewTarget }, index) => (
<DiffRowSpacer height={rowWindow.beforeHeight} />
{visibleUnifiedLines.map(({ entry: { line, key, reviewTarget }, index }) => (
<View key={key} testID={`diff-code-row-${index}`}>
<DiffTextLine
line={line}
@@ -1432,6 +1560,7 @@ export function DiffFileBody({
/>
</View>
))}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</DiffScroll>
</View>
@@ -1828,7 +1957,9 @@ const EMPTY_PATH_LIST: string[] = [];
interface DiffFileMetrics {
contentLength: number;
splitRows?: SplitDiffRow[];
splitLineCount?: number;
unifiedLines?: ReturnType<typeof buildUnifiedDiffLines>;
unifiedLineCount: number;
}
@@ -1855,11 +1986,23 @@ function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics {
function getSplitDiffLineCount(file: ParsedDiffFile): number {
const metrics = getDiffFileMetrics(file);
if (metrics.splitLineCount === undefined) {
metrics.splitLineCount = buildSplitDiffRows(file).length;
metrics.splitLineCount = getCachedSplitDiffRows(file).length;
}
return metrics.splitLineCount;
}
function getCachedSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const metrics = getDiffFileMetrics(file);
metrics.splitRows ??= buildSplitDiffRows(file);
return metrics.splitRows;
}
function getCachedUnifiedDiffLines(file: ParsedDiffFile): ReturnType<typeof buildUnifiedDiffLines> {
const metrics = getDiffFileMetrics(file);
metrics.unifiedLines ??= buildUnifiedDiffLines(file);
return metrics.unifiedLines;
}
function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
@@ -2062,6 +2205,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
[allFolderPathSet, collapsedFolders],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffViewport = useMemo(() => createDiffViewport(), []);
useEffect(() => () => diffViewport.dispose(), [diffViewport]);
const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact });
const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar;
const consumedFocusRequestRef = useRef<string | null>(null);
@@ -2216,9 +2361,13 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
diffViewport.update({
top: diffListScrollOffsetRef.current,
height: diffListViewportHeightRef.current,
});
updateScrollbarOffset(event);
},
[updateScrollbarOffset],
[diffViewport, updateScrollbarOffset],
);
const handleDiffListLayout = useCallback(
@@ -2228,9 +2377,10 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
return;
}
diffListViewportHeightRef.current = height;
diffViewport.update({ top: diffListScrollOffsetRef.current, height });
updateScrollbarLayout(event);
},
[updateScrollbarLayout],
[diffViewport, updateScrollbarLayout],
);
const computeItemOffset = useCallback(
@@ -2387,7 +2537,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
({ item, index }: { item: DiffFlatItem; index: number }) => {
if (item.type === "folder") {
return (
<DiffFolderRow
@@ -2441,6 +2591,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
return (
<DiffFileBody
file={item.file}
viewport={diffViewport}
bodyOffset={sumHeightsBefore(flatItems, index, getFlatItemHeight)}
layout={layout}
wrapLines={wrapLines}
codeFontSize={codeFontSize}
@@ -2453,6 +2605,9 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
},
[
codeFontSize,
diffViewport,
flatItems,
getFlatItemHeight,
handleBodyHeightChange,
handleFolderRowHeightChange,
handleHeaderHeightChange,
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createDiffRowWindow } from "./diff-virtualization";
describe("createDiffRowWindow", () => {
it("renders only the viewport and overscan while preserving the full height", () => {
const rows = Array.from({ length: 1_200 }, () => 18);
const window = createDiffRowWindow(rows, {
viewportTop: 0,
viewportHeight: 900,
overscan: 900,
});
expect(window.startIndex).toBe(0);
expect(window.endIndex).toBe(100);
expect(window.beforeHeight).toBe(0);
expect(window.afterHeight).toBe(19_800);
expect(window.beforeHeight + window.renderedHeight + window.afterHeight).toBe(21_600);
});
it("finds a deep variable-height window without replaying earlier rows", () => {
const rows = [18, 18, 90, 18, 150, 18, 18];
const window = createDiffRowWindow(rows, {
viewportTop: 120,
viewportHeight: 60,
overscan: 0,
});
expect(window).toEqual({
startIndex: 2,
endIndex: 5,
beforeHeight: 36,
renderedHeight: 258,
afterHeight: 36,
totalHeight: 330,
});
});
it("returns an empty row window when an overscanned body is still offscreen", () => {
const window = createDiffRowWindow([18, 18, 18], {
viewportTop: -2_000,
viewportHeight: 800,
overscan: 600,
});
expect(window).toEqual({
startIndex: 0,
endIndex: 0,
beforeHeight: 0,
renderedHeight: 0,
afterHeight: 54,
totalHeight: 54,
});
});
});
+143
View File
@@ -0,0 +1,143 @@
export interface DiffViewportWindow {
viewportTop: number;
viewportHeight: number;
overscan: number;
}
export interface DiffRowWindow {
startIndex: number;
endIndex: number;
beforeHeight: number;
renderedHeight: number;
afterHeight: number;
totalHeight: number;
}
export interface DiffViewportSnapshot {
top: number;
height: number;
}
export interface DiffViewport {
getSnapshot: () => DiffViewportSnapshot;
subscribe: (listener: () => void) => () => void;
update: (snapshot: DiffViewportSnapshot) => void;
dispose: () => void;
}
/** Coalesce native/web scroll events so diff bodies select rows at most once per frame. */
export function createDiffViewport(): DiffViewport {
let snapshot: DiffViewportSnapshot = { top: 0, height: 0 };
let pending = snapshot;
let frame: number | null = null;
const listeners = new Set<() => void>();
const flush = () => {
frame = null;
if (pending.top === snapshot.top && pending.height === snapshot.height) {
return;
}
snapshot = pending;
for (const listener of listeners) {
listener();
}
};
return {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
update(nextSnapshot) {
pending = nextSnapshot;
frame ??= requestAnimationFrame(flush);
},
dispose() {
if (frame !== null) {
cancelAnimationFrame(frame);
}
frame = null;
listeners.clear();
},
};
}
/**
* Select the contiguous diff rows that intersect a viewport while reserving
* exact spacer heights for everything outside it.
*/
export function createDiffRowWindow(
rowHeights: readonly number[],
viewport: DiffViewportWindow,
): DiffRowWindow {
const offsets = Array.from({ length: rowHeights.length + 1 }, () => 0);
offsets[0] = 0;
for (let index = 0; index < rowHeights.length; index += 1) {
offsets[index + 1] = offsets[index] + rowHeights[index];
}
const totalHeight = offsets[rowHeights.length];
const visibleTop = viewport.viewportTop - viewport.overscan;
const visibleBottom = viewport.viewportTop + viewport.viewportHeight + viewport.overscan;
if (visibleBottom <= 0) {
return emptyWindow(0, 0, totalHeight);
}
if (visibleTop >= totalHeight) {
return emptyWindow(rowHeights.length, totalHeight, 0);
}
const startIndex = firstIndexWhoseEndExceeds(offsets, Math.max(0, visibleTop));
const endIndex = firstIndexWhoseStartReaches(offsets, Math.min(totalHeight, visibleBottom));
const beforeHeight = offsets[startIndex];
const renderedHeight = offsets[endIndex] - beforeHeight;
return {
startIndex,
endIndex,
beforeHeight,
renderedHeight,
afterHeight: totalHeight - offsets[endIndex],
totalHeight,
};
}
function emptyWindow(index: number, beforeHeight: number, afterHeight: number): DiffRowWindow {
return {
startIndex: index,
endIndex: index,
beforeHeight,
renderedHeight: 0,
afterHeight,
totalHeight: beforeHeight + afterHeight,
};
}
function firstIndexWhoseEndExceeds(offsets: readonly number[], target: number): number {
let low = 1;
let high = offsets.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if ((offsets[middle] ?? Number.POSITIVE_INFINITY) > target) {
high = middle;
} else {
low = middle + 1;
}
}
return Math.min(offsets.length - 1, low - 1);
}
function firstIndexWhoseStartReaches(offsets: readonly number[], target: number): number {
let low = 0;
let high = offsets.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (offsets[middle] >= target) {
high = middle;
} else {
low = middle + 1;
}
}
return low;
}
@@ -49,7 +49,7 @@ afterEach(() => {
});
describe("ensureAgentIsInitialized", () => {
it("requests a bounded projected tail when authoritative history is loaded", () => {
it("catches up after loaded authoritative history", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -74,13 +74,14 @@ describe("ensureAgentIsInitialized", () => {
serverId,
agentId,
request: {
direction: "tail",
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("requests a bounded projected tail when no authoritative cursor is available", () => {
@@ -127,6 +128,8 @@ describe("ensureAgentIsInitialized", () => {
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
range: null,
hasOlder: false,
},
});
@@ -151,6 +154,52 @@ describe("ensureAgentIsInitialized", () => {
]);
});
it("catches up after the restored canonical replica range", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().restoreSessionReplica(serverId, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
timeline: {
agentId,
items: [
{
kind: "assistant_message",
id: "canonical-item",
text: "Canonical before restart",
timelineCursor: { epoch: "epoch-1", seq: 12 },
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
range: { epoch: "epoch-1", startSeq: 10, endSeq: 12 },
hasOlder: true,
},
});
void ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
expect(runtime.requests).toEqual([
{
serverId,
agentId,
request: {
direction: "after",
cursor: { epoch: "epoch-1", seq: 12 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("times out initialization after 65 seconds", async () => {
vi.useFakeTimers();
const client = new FakeDaemonClient();
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
@@ -11,7 +11,7 @@ import {
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
@@ -51,7 +51,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
return existing.promise;
}
const timelineRequest = planTimelineTailFetch();
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
const timelineRequest = planTimelineResumeFetch(
timeline.status === "synced" ? timeline.range : null,
);
const deferred = createInitDeferred(key, timelineRequest.direction);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
@@ -39,6 +39,7 @@ describe("splitPinnedSidebarGroups", () => {
pinnedWorkspaceKeys: ["w1"],
pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" },
},
pinnedWorkspaceOrder: [],
});
expect(result.pinnedChats).toHaveLength(1);
expect(result.unpinnedProjects).toHaveLength(0);
@@ -49,6 +50,7 @@ describe("splitPinnedSidebarGroups", () => {
const result = splitPinnedSidebarGroups({
projects,
keys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} },
pinnedWorkspaceOrder: [],
});
expect(result.unpinnedProjects).toHaveLength(1);
});
@@ -61,6 +63,7 @@ describe("splitPinnedSidebarGroups", () => {
pinnedWorkspaceKeys: ["w1"],
pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" },
},
pinnedWorkspaceOrder: [],
});
expect(result.pinnedChats.map((w) => w.workspaceKey)).toEqual(["w1"]);
expect(result.unpinnedProjects[0]?.workspaces.map((w) => w.workspaceKey)).toEqual(["w2"]);
@@ -77,6 +80,7 @@ describe("splitPinnedSidebarGroups", () => {
newer: "2026-02-01T00:00:00Z",
},
},
pinnedWorkspaceOrder: [],
});
expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([
@@ -84,4 +88,26 @@ describe("splitPinnedSidebarGroups", () => {
"older",
]);
});
it("applies the saved order while keeping a newly pinned chat first", () => {
const projects = [project("p1", [placement("older"), placement("newer"), placement("new")])];
const result = splitPinnedSidebarGroups({
projects,
keys: {
pinnedWorkspaceKeys: ["older", "newer", "new"],
pinnedAtByKey: {
older: "2026-01-01T00:00:00Z",
newer: "2026-02-01T00:00:00Z",
new: "2026-03-01T00:00:00Z",
},
},
pinnedWorkspaceOrder: ["older", "newer"],
});
expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([
"new",
"older",
"newer",
]);
});
});
+11 -2
View File
@@ -5,6 +5,7 @@ import type {
SidebarProjectEntry,
SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model";
import { useSessionStore } from "@/stores/session-store";
export interface PinnedSidebarKeys {
@@ -101,8 +102,9 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid
export function splitPinnedSidebarGroups(input: {
projects: SidebarProjectEntry[];
keys: PinnedSidebarKeys;
pinnedWorkspaceOrder: string[];
}): PinnedSidebarGroups {
const { projects, keys } = input;
const { projects, keys, pinnedWorkspaceOrder } = input;
if (keys.pinnedWorkspaceKeys.length === 0) {
return { pinnedChats: [], unpinnedProjects: projects };
}
@@ -138,5 +140,12 @@ export function splitPinnedSidebarGroups(input: {
),
);
return { pinnedChats, unpinnedProjects };
return {
pinnedChats: applyStoredOrdering({
items: pinnedChats,
storedOrder: pinnedWorkspaceOrder,
getKey: (workspace) => workspace.workspaceKey,
}),
unpinnedProjects,
};
}
@@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import {
useHydratedWorkspaceServerIds,
useWorkspaceDirectoryServerIds,
} from "@/stores/session-store-hooks";
import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { useHostProjects } from "@/projects/host-projects";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
@@ -132,8 +135,9 @@ export function useSidebarWorkspacesList(options?: {
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds);
const hostProjects = useHostProjects(hydratedServerIds);
const hostProjects = useHostProjects(directoryServerIds);
const sidebarModel = useMemo(
() =>
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
common: {
@@ -523,6 +524,8 @@ export const ar: TranslationResources = {
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
elementCopied: "تم نسخ العنصر إلى الحافظة",
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
selectorLoading: "انتظر حتى ينتهي تحميل الصفحة",
selectorFailed: "تعذّر بدء محدد العنصر",
},
annotate: {
title: "التعليق على العنصر",
@@ -1615,6 +1618,7 @@ export const ar: TranslationResources = {
archiveTooltip: "أرشفة الوكيل الفرعي",
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين",
archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1783,8 +1787,10 @@ export const ar: TranslationResources = {
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
plugins: "Plugins",
host: "نظرة عامة",
},
plugins: pluginSettings.ar,
metadataGeneration: {
title: "إنشاء البيانات الوصفية",
description:
+51
View File
@@ -522,6 +522,8 @@ export const en = {
screenshotCopied: "Copied screenshot to clipboard",
elementCopied: "Copied element to clipboard",
screenshotFailed: "Couldn't copy screenshot",
selectorLoading: "Wait for the page to finish loading",
selectorFailed: "Couldn't start element selector",
},
annotate: {
title: "Annotate element",
@@ -1626,6 +1628,7 @@ export const en = {
archiveTooltip: "Archive subagent",
archiveFinishedAction: "Archive finished subagents",
archiveFinishedTooltip: "Archive finished",
archiveFinishedRetry: "Retry ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1794,8 +1797,56 @@ export const en = {
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
plugins: "Plugins",
host: "Overview",
},
plugins: {
title: "Plugins",
trustedTitle: "Plugins are trusted code",
trustedDescription:
"They run on this host and inside the app without sandboxing. Install only code you trust.",
globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin",
directoryLabel: "Plugin directory",
directoryPlaceholder: "/absolute/path/on/host",
idLabel: "Plugin installation ID",
idHint: "Leave blank to use paseo-plugin.json",
idPlaceholder: "Manifest default",
install: "Install directory",
installing: "Installing…",
status: { running: "running", disabled: "disabled", failed: "failed" },
actions: {
reload: "Reload",
reloading: "Reloading…",
enable: "Enable",
enabling: "Enabling…",
disable: "Disable",
disabling: "Disabling…",
remove: "Remove",
removing: "Removing…",
},
feedback: {
installed: "Installed {{id}}",
reload: "Reloaded {{id}}",
enable: "Enabled {{id}}",
disable: "Disabled {{id}}",
remove: "Removed {{id}}",
kept: "Kept {{id}}",
globalEnabled: "Plugins enabled",
globalDisabled: "Plugins disabled",
},
removeConfirmTitle: "Remove {{id}}?",
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
states: {
offlineTitle: "Plugin host is offline",
offlineDescription: "Reconnect to this host to manage its plugins.",
updateTitle: "Update this host to manage plugins",
loading: "Loading plugins…",
errorTitle: "Unable to load plugins",
retry: "Retry",
empty: "No plugins configured",
},
},
metadataGeneration: {
title: "Metadata generation",
description:
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
common: {
@@ -527,6 +528,8 @@ export const es: TranslationResources = {
screenshotCopied: "Captura copiada al portapapeles",
elementCopied: "Elemento copiado al portapapeles",
screenshotFailed: "No se pudo copiar la captura",
selectorLoading: "Espera a que la página termine de cargar",
selectorFailed: "No se pudo iniciar el selector de elementos",
},
annotate: {
title: "Anotar elemento",
@@ -1658,6 +1661,7 @@ export const es: TranslationResources = {
archiveTooltip: "Subagente de archivo",
archiveFinishedAction: "Archivar subagentes finalizados",
archiveFinishedTooltip: "Archivar finalizados",
archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1826,8 +1830,10 @@ export const es: TranslationResources = {
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
plugins: "Plugins",
host: "Resumen",
},
plugins: pluginSettings.es,
metadataGeneration: {
title: "Generación de metadatos",
description:
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
common: {
@@ -527,6 +528,8 @@ export const fr: TranslationResources = {
screenshotCopied: "Capture d'écran copiée dans le presse-papiers",
elementCopied: "Élément copié dans le presse-papiers",
screenshotFailed: "Impossible de copier la capture",
selectorLoading: "Attendez la fin du chargement de la page",
selectorFailed: "Impossible de démarrer le sélecteur d’élément",
},
annotate: {
title: "Annoter l'élément",
@@ -1662,6 +1665,7 @@ export const fr: TranslationResources = {
archiveTooltip: "Sous-agent d'archivage",
archiveFinishedAction: "Archiver les sous-agents terminés",
archiveFinishedTooltip: "Archiver les terminés",
archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1830,8 +1834,10 @@ export const fr: TranslationResources = {
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
plugins: "Plugins",
host: "Aperçu",
},
plugins: pluginSettings.fr,
metadataGeneration: {
title: "Génération de métadonnées",
description:
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
common: {
@@ -527,6 +528,8 @@ export const ja: TranslationResources = {
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
elementCopied: "要素をクリップボードにコピーしました",
screenshotFailed: "スクリーンショットをコピーできませんでした",
selectorLoading: "ページの読み込みが完了するまでお待ちください",
selectorFailed: "要素セレクターを開始できませんでした",
},
annotate: {
title: "要素に注釈を付ける",
@@ -1631,6 +1634,7 @@ export const ja: TranslationResources = {
archiveTooltip: "サブエージェントをアーカイブ",
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ",
archiveFinishedRetry: "再試行 ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1799,8 +1803,10 @@ export const ja: TranslationResources = {
providers: "プロバイダー",
usage: "使用状況",
terminals: "ターミナル",
plugins: "プラグイン",
host: "概要",
},
plugins: pluginSettings.ja,
metadataGeneration: {
title: "メタデータ生成",
description:
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
common: {
@@ -524,6 +525,8 @@ export const ko: TranslationResources = {
screenshotCopied: "스크린샷을 클립보드에 복사했습니다.",
elementCopied: "요소를 클립보드에 복사했습니다.",
screenshotFailed: "스크린샷을 복사할 수 없습니다.",
selectorLoading: "페이지 로딩이 끝날 때까지 기다려 주세요.",
selectorFailed: "요소 선택기를 시작할 수 없습니다.",
},
annotate: {
title: "요소에 주석 달기",
@@ -1625,6 +1628,7 @@ export const ko: TranslationResources = {
archiveTooltip: "서브에이전트 보관",
archiveFinishedAction: "완료된 하위 에이전트 보관",
archiveFinishedTooltip: "아카이브 완료",
archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1793,8 +1797,10 @@ export const ko: TranslationResources = {
providers: "프로바이더",
usage: "사용량",
terminals: "터미널",
plugins: "플러그인",
host: "개요",
},
plugins: pluginSettings.ko,
metadataGeneration: {
title: "메타데이터 생성",
description:
@@ -0,0 +1,380 @@
const shared = {
directoryPlaceholder: "/absolute/path/on/host",
} as const;
export const pluginSettings = {
ar: {
...shared,
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
trustedDescription:
"تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.",
globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
directoryLabel: "مجلد الإضافة",
idLabel: "معرّف تثبيت الإضافة",
idHint: "اتركه فارغًا لاستخدام paseo-plugin.json",
idPlaceholder: "القيمة الافتراضية للبيان",
install: "تثبيت المجلد",
installing: "جارٍ التثبيت…",
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
actions: {
reload: "إعادة تحميل",
reloading: "جارٍ إعادة التحميل…",
enable: "تفعيل",
enabling: "جارٍ التفعيل…",
disable: "تعطيل",
disabling: "جارٍ التعطيل…",
remove: "إزالة",
removing: "جارٍ الإزالة…",
},
feedback: {
installed: "تم تثبيت {{id}}",
reload: "تمت إعادة تحميل {{id}}",
enable: "تم تفعيل {{id}}",
disable: "تم تعطيل {{id}}",
remove: "تمت إزالة {{id}}",
kept: "تم الإبقاء على {{id}}",
globalEnabled: "تم تفعيل الإضافات",
globalDisabled: "تم تعطيل الإضافات",
},
removeConfirmTitle: "إزالة {{id}}؟",
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
states: {
offlineTitle: "مضيف الإضافات غير متصل",
offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.",
updateTitle: "حدّث هذا المضيف لإدارة الإضافات",
loading: "جارٍ تحميل الإضافات…",
errorTitle: "تعذّر تحميل الإضافات",
retry: "إعادة المحاولة",
empty: "لا توجد إضافات مضبوطة",
},
},
es: {
...shared,
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
trustedDescription:
"Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.",
globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados",
directoryLabel: "Directorio del plugin",
idLabel: "ID de instalación del plugin",
idHint: "Déjalo vacío para usar paseo-plugin.json",
idPlaceholder: "Valor del manifiesto",
install: "Instalar directorio",
installing: "Instalando…",
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
actions: {
reload: "Recargar",
reloading: "Recargando…",
enable: "Activar",
enabling: "Activando…",
disable: "Desactivar",
disabling: "Desactivando…",
remove: "Eliminar",
removing: "Eliminando…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recargado",
enable: "{{id}} activado",
disable: "{{id}} desactivado",
remove: "{{id}} eliminado",
kept: "Se conservó {{id}}",
globalEnabled: "Plugins activados",
globalDisabled: "Plugins desactivados",
},
removeConfirmTitle: "¿Eliminar {{id}}?",
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
states: {
offlineTitle: "El host de plugins está sin conexión",
offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.",
updateTitle: "Actualiza este host para gestionar plugins",
loading: "Cargando plugins…",
errorTitle: "No se pudieron cargar los plugins",
retry: "Reintentar",
empty: "No hay plugins configurados",
},
},
fr: {
...shared,
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
trustedDescription:
"Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.",
globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés",
directoryLabel: "Dossier du plugin",
idLabel: "ID d’installation du plugin",
idHint: "Laissez vide pour utiliser paseo-plugin.json",
idPlaceholder: "Valeur du manifeste",
install: "Installer le dossier",
installing: "Installation…",
status: { running: "actif", disabled: "désactivé", failed: "échec" },
actions: {
reload: "Recharger",
reloading: "Rechargement…",
enable: "Activer",
enabling: "Activation…",
disable: "Désactiver",
disabling: "Désactivation…",
remove: "Supprimer",
removing: "Suppression…",
},
feedback: {
installed: "{{id}} installé",
reload: "{{id}} rechargé",
enable: "{{id}} activé",
disable: "{{id}} désactivé",
remove: "{{id}} supprimé",
kept: "{{id}} conservé",
globalEnabled: "Plugins activés",
globalDisabled: "Plugins désactivés",
},
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
states: {
offlineTitle: "L’hôte des plugins est hors ligne",
offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.",
updateTitle: "Mettez cet hôte à jour pour gérer les plugins",
loading: "Chargement des plugins…",
errorTitle: "Impossible de charger les plugins",
retry: "Réessayer",
empty: "Aucun plugin configuré",
},
},
ja: {
...shared,
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
trustedDescription:
"ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。",
globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ",
directoryLabel: "プラグインディレクトリ",
idLabel: "プラグインのインストール ID",
idHint: "paseo-plugin.json を使う場合は空欄にします",
idPlaceholder: "マニフェストの既定値",
install: "ディレクトリをインストール",
installing: "インストール中…",
status: { running: "実行中", disabled: "無効", failed: "失敗" },
actions: {
reload: "再読み込み",
reloading: "再読み込み中…",
enable: "有効化",
enabling: "有効化中…",
disable: "無効化",
disabling: "無効化中…",
remove: "削除",
removing: "削除中…",
},
feedback: {
installed: "{{id}} をインストールしました",
reload: "{{id}} を再読み込みしました",
enable: "{{id}} を有効にしました",
disable: "{{id}} を無効にしました",
remove: "{{id}} を削除しました",
kept: "{{id}} を保持しました",
globalEnabled: "プラグインを有効にしました",
globalDisabled: "プラグインを無効にしました",
},
removeConfirmTitle: "{{id}} を削除しますか?",
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
states: {
offlineTitle: "プラグインホストはオフラインです",
offlineDescription: "プラグインを管理するにはホストに再接続してください。",
updateTitle: "プラグインを管理するにはホストを更新してください",
loading: "プラグインを読み込み中…",
errorTitle: "プラグインを読み込めません",
retry: "再試行",
empty: "設定済みプラグインはありません",
},
},
ko: {
...shared,
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.",
globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치",
directoryLabel: "플러그인 디렉터리",
idLabel: "플러그인 설치 ID",
idHint: "paseo-plugin.json을 사용하려면 비워 두세요",
idPlaceholder: "매니페스트 기본값",
install: "디렉터리 설치",
installing: "설치 중…",
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
actions: {
reload: "다시 로드",
reloading: "다시 로드 중…",
enable: "활성화",
enabling: "활성화 중…",
disable: "비활성화",
disabling: "비활성화 중…",
remove: "제거",
removing: "제거 중…",
},
feedback: {
installed: "{{id}} 설치됨",
reload: "{{id}} 다시 로드됨",
enable: "{{id}} 활성화됨",
disable: "{{id}} 비활성화됨",
remove: "{{id}} 제거됨",
kept: "{{id}} 유지됨",
globalEnabled: "플러그인 활성화됨",
globalDisabled: "플러그인 비활성화됨",
},
removeConfirmTitle: "{{id}}을(를) 제거할까요?",
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
states: {
offlineTitle: "플러그인 호스트가 오프라인입니다",
offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.",
updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요",
loading: "플러그인 로드 중…",
errorTitle: "플러그인을 로드할 수 없습니다",
retry: "다시 시도",
empty: "설정된 플러그인이 없습니다",
},
},
"pt-BR": {
...shared,
title: "Plugins",
trustedTitle: "Plugins são código confiável",
trustedDescription:
"Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.",
globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados",
directoryLabel: "Diretório do plugin",
idLabel: "ID de instalação do plugin",
idHint: "Deixe vazio para usar paseo-plugin.json",
idPlaceholder: "Padrão do manifesto",
install: "Instalar diretório",
installing: "Instalando…",
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
actions: {
reload: "Recarregar",
reloading: "Recarregando…",
enable: "Ativar",
enabling: "Ativando…",
disable: "Desativar",
disabling: "Desativando…",
remove: "Remover",
removing: "Removendo…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recarregado",
enable: "{{id}} ativado",
disable: "{{id}} desativado",
remove: "{{id}} removido",
kept: "{{id}} mantido",
globalEnabled: "Plugins ativados",
globalDisabled: "Plugins desativados",
},
removeConfirmTitle: "Remover {{id}}?",
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
states: {
offlineTitle: "O host de plugins está offline",
offlineDescription: "Reconecte este host para gerenciar seus plugins.",
updateTitle: "Atualize este host para gerenciar plugins",
loading: "Carregando plugins…",
errorTitle: "Não foi possível carregar os plugins",
retry: "Tentar novamente",
empty: "Nenhum plugin configurado",
},
},
ru: {
...shared,
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
trustedDescription:
"Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.",
globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов",
directoryLabel: "Каталог плагина",
idLabel: "ID установки плагина",
idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json",
idPlaceholder: "Значение из манифеста",
install: "Установить каталог",
installing: "Установка…",
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
actions: {
reload: "Перезагрузить",
reloading: "Перезагрузка…",
enable: "Включить",
enabling: "Включение…",
disable: "Отключить",
disabling: "Отключение…",
remove: "Удалить",
removing: "Удаление…",
},
feedback: {
installed: "{{id}} установлен",
reload: "{{id}} перезагружен",
enable: "{{id}} включён",
disable: "{{id}} отключён",
remove: "{{id}} удалён",
kept: "{{id}} сохранён",
globalEnabled: "Плагины включены",
globalDisabled: "Плагины отключены",
},
removeConfirmTitle: "Удалить {{id}}?",
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
states: {
offlineTitle: "Хост плагинов не в сети",
offlineDescription: "Подключите хост снова, чтобы управлять плагинами.",
updateTitle: "Обновите хост для управления плагинами",
loading: "Загрузка плагинов…",
errorTitle: "Не удалось загрузить плагины",
retry: "Повторить",
empty: "Нет настроенных плагинов",
},
},
"zh-CN": {
...shared,
title: "插件",
trustedTitle: "插件是受信任的代码",
trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。",
globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关",
directoryLabel: "插件目录",
idLabel: "插件安装 ID",
idHint: "留空以使用 paseo-plugin.json",
idPlaceholder: "清单默认值",
install: "安装目录",
installing: "正在安装…",
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
actions: {
reload: "重新加载",
reloading: "正在重新加载…",
enable: "启用",
enabling: "正在启用…",
disable: "禁用",
disabling: "正在禁用…",
remove: "移除",
removing: "正在移除…",
},
feedback: {
installed: "已安装 {{id}}",
reload: "已重新加载 {{id}}",
enable: "已启用 {{id}}",
disable: "已禁用 {{id}}",
remove: "已移除 {{id}}",
kept: "已保留 {{id}}",
globalEnabled: "插件已启用",
globalDisabled: "插件已禁用",
},
removeConfirmTitle: "移除 {{id}}?",
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
states: {
offlineTitle: "插件主机离线",
offlineDescription: "重新连接此主机以管理其插件。",
updateTitle: "更新此主机以管理插件",
loading: "正在加载插件…",
errorTitle: "无法加载插件",
retry: "重试",
empty: "没有已配置的插件",
},
},
} as const;
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
common: {
@@ -527,6 +528,8 @@ export const ptBR: TranslationResources = {
screenshotCopied: "Captura copiada para a área de transferência",
elementCopied: "Elemento copiado para a área de transferência",
screenshotFailed: "Não foi possível copiar a captura",
selectorLoading: "Aguarde o carregamento da página terminar",
selectorFailed: "Não foi possível iniciar o seletor de elemento",
},
annotate: {
title: "Anotar elemento",
@@ -1644,6 +1647,7 @@ export const ptBR: TranslationResources = {
archiveTooltip: "Arquivar subagente",
archiveFinishedAction: "Arquivar subagentes concluídos",
archiveFinishedTooltip: "Arquivar concluídos",
archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1812,8 +1816,10 @@ export const ptBR: TranslationResources = {
providers: "Provedores",
usage: "Uso",
terminals: "Terminais",
plugins: "Plugins",
host: "Visão geral",
},
plugins: pluginSettings["pt-BR"],
metadataGeneration: {
title: "Geração de metadados",
description:
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
common: {
@@ -527,6 +528,8 @@ export const ru: TranslationResources = {
screenshotCopied: "Снимок скопирован в буфер обмена",
elementCopied: "Элемент скопирован в буфер обмена",
screenshotFailed: "Не удалось скопировать снимок",
selectorLoading: "Дождитесь окончания загрузки страницы",
selectorFailed: "Не удалось запустить выбор элемента",
},
annotate: {
title: "Аннотировать элемент",
@@ -1649,6 +1652,7 @@ export const ru: TranslationResources = {
archiveTooltip: "Архивный субагент",
archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные",
archiveFinishedRetry: "Повторить ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1817,8 +1821,10 @@ export const ru: TranslationResources = {
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
plugins: "Plugins",
host: "Обзор",
},
plugins: pluginSettings.ru,
metadataGeneration: {
title: "Создание метаданных",
description:
+6
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
common: {
@@ -523,6 +524,8 @@ export const zhCN: TranslationResources = {
screenshotCopied: "已将截图复制到剪贴板",
elementCopied: "已将元素复制到剪贴板",
screenshotFailed: "无法复制截图",
selectorLoading: "请等待页面加载完成",
selectorFailed: "无法启动元素选择器",
},
annotate: {
title: "标注元素",
@@ -1595,6 +1598,7 @@ export const zhCN: TranslationResources = {
archiveTooltip: "归档 subagent",
archiveFinishedAction: "归档已完成的 subagent",
archiveFinishedTooltip: "归档已完成项",
archiveFinishedRetry: "重试 ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1763,8 +1767,10 @@ export const zhCN: TranslationResources = {
providers: "Providers",
usage: "使用情况",
terminals: "Terminals",
plugins: "插件",
host: "概览",
},
plugins: pluginSettings["zh-CN"],
metadataGeneration: {
title: "元数据生成",
description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
+20 -8
View File
@@ -88,7 +88,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme";
import {
useHideFinishedProviderSubagents,
useArchiveFinishedSubagents,
useArchiveSubagent,
useDetachSubagent,
useSubagentsForParent,
@@ -1228,7 +1228,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
// when only toast state changes (which does not affect any draft field).
const {
text,
setText,
editText,
replaceText,
textReplacementKey,
attachments,
setAttachments,
clear,
@@ -1239,7 +1241,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const agentInputDraft = useMemo(
(): AgentInputDraft => ({
text,
setText,
editText,
replaceText,
textReplacementKey,
attachments,
setAttachments,
clear,
@@ -1249,7 +1253,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}),
[
text,
setText,
editText,
replaceText,
textReplacementKey,
attachments,
setAttachments,
clear,
@@ -1296,7 +1302,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<RewindComposerRestoreProvider
text={agentInputDraft.text}
setText={agentInputDraft.replaceText}
>
<View style={styles.root}>
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
{contentContainer}
@@ -1523,9 +1532,10 @@ function ActiveAgentComposer({
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
@@ -1619,7 +1629,8 @@ function ActiveAgentComposer({
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onArchiveFinished={handleHideFinishedProviderSubagents}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
<Composer
@@ -1629,7 +1640,8 @@ function ActiveAgentComposer({
externalKeyboardShift
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
onChangeText={agentInputDraft.editText}
textReplacementKey={agentInputDraft.textReplacementKey}
attachments={agentInputDraft.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
@@ -0,0 +1,5 @@
export {
PluginResourceComposerAttachmentSchema,
pluginResourceAttachmentToAgentAttachment,
type PluginResourceComposerAttachment,
} from "./model";

Some files were not shown because too many files have changed in this diff Show More