mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0290b0e35b | ||
|
|
07ffe243fb | ||
|
|
ae3c0119f9 | ||
|
|
5d195e7e15 |
@@ -18,7 +18,6 @@ on:
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -10,7 +10,6 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
@@ -12,7 +12,6 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -40,7 +40,6 @@ 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 |
|
||||
|
||||
@@ -23,7 +23,6 @@ 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
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the 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 or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
|
||||
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
|
||||
+7
-16
@@ -69,7 +69,6 @@ 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 |
|
||||
@@ -98,27 +97,19 @@ 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 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.
|
||||
- `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.
|
||||
- `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 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.
|
||||
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.
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
|
||||
+34
-20
@@ -30,7 +30,20 @@ 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 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.
|
||||
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.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
@@ -49,11 +62,12 @@ $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/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
├── schedules/ # Legacy schedule import source, left untouched
|
||||
│ └── {scheduleId}.json
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ ├── workspaces.json # Workspace registry
|
||||
@@ -61,10 +75,10 @@ $PASEO_HOME/
|
||||
├── runtime/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
└── push-tokens.json # Legacy push token import source, left untouched
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -175,13 +189,6 @@ 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,
|
||||
@@ -285,9 +292,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. Run `paseo reload`
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -332,9 +339,11 @@ Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
**Table:** `paseo.db:schedules`
|
||||
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
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.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
@@ -449,15 +458,20 @@ than treating it as valid.
|
||||
|
||||
## 6. Push Token Store
|
||||
|
||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||
**Table:** `paseo.db:push_tokens`
|
||||
|
||||
Legacy import source:
|
||||
|
||||
```json
|
||||
{
|
||||
"tokens": ["ExponentPushToken[...]", ...]
|
||||
"subscriptions": [{ "token": "ExponentPushToken[...]", "expiresAt": "2026-08-12T00:00:00.000Z" }]
|
||||
}
|
||||
```
|
||||
|
||||
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -266,8 +266,8 @@ the daemon-global Git process limits in `$PASEO_HOME/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
|
||||
semantics, and environment-variable overrides.
|
||||
|
||||
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
# 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.
|
||||
+9
-15
@@ -85,22 +85,16 @@ 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 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.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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
@@ -1 +1 @@
|
||||
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
|
||||
sha256-BeS2/PYAPoG4U3q7N2qOVuPC1T5sDQ+c9hv2cTn3KRU=
|
||||
|
||||
Generated
+17
-540
@@ -12,7 +12,6 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
@@ -7763,16 +7762,6 @@
|
||||
"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",
|
||||
@@ -9569,10 +9558,6 @@
|
||||
"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",
|
||||
@@ -36123,8 +36108,6 @@
|
||||
"@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",
|
||||
@@ -37180,6 +37163,9 @@
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6",
|
||||
"zx": "^8.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.20.0"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/@vitest/expect": {
|
||||
@@ -38588,22 +38574,6 @@
|
||||
"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",
|
||||
@@ -38842,7 +38812,6 @@
|
||||
"@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",
|
||||
@@ -38850,13 +38819,11 @@
|
||||
"@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",
|
||||
@@ -38881,7 +38848,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/ws": "^8.5.8",
|
||||
@@ -38890,6 +38857,9 @@
|
||||
"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": {
|
||||
@@ -39020,468 +38990,6 @@
|
||||
"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",
|
||||
@@ -39500,6 +39008,16 @@
|
||||
"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",
|
||||
@@ -39636,47 +39154,6 @@
|
||||
"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",
|
||||
|
||||
+4
-7
@@ -23,7 +23,6 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
@@ -47,21 +46,19 @@
|
||||
"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,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-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": "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: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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -140,8 +139,7 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
[withAndroidAsyncStorageSize, 10],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
|
||||
@@ -30,12 +30,6 @@ 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,
|
||||
@@ -342,64 +336,6 @@ 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 },
|
||||
@@ -1077,13 +1013,6 @@ 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,41 +26,9 @@ 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,10 +13,9 @@ import {
|
||||
import {
|
||||
commitTimelineTurnsWhileDisconnected,
|
||||
disconnectViewedTimeline,
|
||||
expectOneResumeCheckWithoutTail,
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
expectOneTailCheckWithoutCatchUpPages,
|
||||
rememberTimelineRequestCounts,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
restoreViewedTimelineWithHeldTail,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
|
||||
test.describe("Agent timeline resume", () => {
|
||||
@@ -30,11 +29,11 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
|
||||
await expectTimelinePromptVisible(page, background.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, background.firstPrompt);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requests);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
|
||||
} finally {
|
||||
gate.restore();
|
||||
@@ -54,10 +53,10 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
|
||||
await expectTimelinePresentationUnchanged(page, presentation);
|
||||
expectOneResumeCheckWithoutTail(gate, requests);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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,13 +669,11 @@ 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 visibleCodeText.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
return page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
})
|
||||
.toBe(fontSize);
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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,8 +34,6 @@ 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",
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
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,7 +10,6 @@ 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`.
|
||||
|
||||
@@ -28,28 +27,39 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.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;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
return false;
|
||||
}, prompt),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
async function waitForCurrentSubmissionExcludedFromCache(
|
||||
@@ -57,34 +67,46 @@ async function waitForCurrentSubmissionExcludedFromCache(
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.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,
|
||||
);
|
||||
})
|
||||
.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),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.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,
|
||||
);
|
||||
})
|
||||
.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),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -126,13 +148,16 @@ 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();
|
||||
@@ -140,6 +165,7 @@ 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,25 +18,7 @@ async function visibleBoundingBox(row: Locator) {
|
||||
return box;
|
||||
}
|
||||
|
||||
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>,
|
||||
) {
|
||||
async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
await expect(rows).toHaveCount(2);
|
||||
const before = await rowTestIds(rows);
|
||||
const sourceBox = await visibleBoundingBox(rows.nth(0));
|
||||
@@ -47,19 +29,15 @@ async function quickDragFirstRowAfterSecond(
|
||||
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
|
||||
|
||||
await page.mouse.move(source.x, source.y);
|
||||
const trailingScrim = rows.nth(0).getByTestId("sidebar-workspace-trailing-scrim");
|
||||
await pressRow(rows);
|
||||
await page.mouse.down();
|
||||
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, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
|
||||
|
||||
@@ -83,7 +61,6 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
|
||||
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}`;
|
||||
@@ -91,17 +68,6 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
|
||||
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,82 +31,6 @@ 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,
|
||||
|
||||
@@ -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: 500 });
|
||||
await expect(svg).toBeVisible({ timeout: 100 });
|
||||
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({ waitUntil: "domcontentloaded" });
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
observeReplicaCacheStorageWrites,
|
||||
readReplicaCacheStorageWriteObserver,
|
||||
resetReplicaCacheStorageWriteObserver,
|
||||
} from "./replica-cache-storage";
|
||||
|
||||
const REPLICA_CACHE_STORAGE_KEY = "@paseo:replica-cache";
|
||||
|
||||
export interface ReplicaCachePerfReport {
|
||||
elapsedMs: number;
|
||||
@@ -14,7 +11,12 @@ export interface ReplicaCachePerfReport {
|
||||
}
|
||||
|
||||
interface ReplicaCachePerfState {
|
||||
lastValue: string | null;
|
||||
resetAt: number;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -24,25 +26,55 @@ declare global {
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheWrites(page: Page): Promise<void> {
|
||||
await observeReplicaCacheStorageWrites(page);
|
||||
await page.addInitScript(() => {
|
||||
window.__replicaCachePerf = { resetAt: performance.now() };
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
|
||||
await resetReplicaCacheStorageWriteObserver(page);
|
||||
await page.evaluate(() => {
|
||||
window.__replicaCachePerf = { resetAt: performance.now() };
|
||||
const lastValue = window.__replicaCachePerf?.lastValue ?? null;
|
||||
window.__replicaCachePerf = {
|
||||
lastValue,
|
||||
resetAt: performance.now(),
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
|
||||
const storage = await readReplicaCacheStorageWriteObserver(page);
|
||||
const elapsedMs = await page.evaluate(() => {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__replicaCachePerf;
|
||||
if (!state) throw new Error("Replica cache performance observer is not installed");
|
||||
return performance.now() - state.resetAt;
|
||||
return {
|
||||
elapsedMs: performance.now() - state.resetAt,
|
||||
redundantWrites: state.redundantWrites,
|
||||
serializedChars: state.serializedChars,
|
||||
storageDurationMs: state.storageDurationMs,
|
||||
writes: state.writes,
|
||||
};
|
||||
});
|
||||
return { elapsedMs, ...storage };
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
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,7 +42,6 @@ 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,7 +41,6 @@ type HostSection =
|
||||
| "providers"
|
||||
| "usage"
|
||||
| "terminals"
|
||||
| "plugins"
|
||||
| "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
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;
|
||||
@@ -144,147 +132,6 @@ 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,6 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
delayAgentBootstrapTailResponse,
|
||||
delayAgentOlderTimelineResponse,
|
||||
@@ -40,12 +39,6 @@ interface TimelineViewportSnapshot {
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
interface PersistedCanonicalTimelineRange {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export interface TimelinePresentationSnapshot {
|
||||
marker: string;
|
||||
position: TimelinePromptPositionSnapshot;
|
||||
@@ -291,45 +284,28 @@ export async function reloadAgentTimelineFromPersistedReplica(
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
const timeline = cache?.hosts?.find(
|
||||
(host) => host.timeline?.agentId === agent.agentId,
|
||||
)?.timeline;
|
||||
return timeline?.items?.length === 50;
|
||||
})
|
||||
.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),
|
||||
)
|
||||
.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,20 +40,12 @@ export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): Timeli
|
||||
};
|
||||
}
|
||||
|
||||
export function expectOneResumeCheckWithoutTail(
|
||||
export function expectOneTailCheckWithoutCatchUpPages(
|
||||
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(
|
||||
@@ -64,7 +56,7 @@ export async function disconnectViewedTimeline(
|
||||
await expectReconnectingToastVisible(page);
|
||||
}
|
||||
|
||||
export async function restoreViewedTimelineWithHeldResponse(
|
||||
export async function restoreViewedTimelineWithHeldTail(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -48,8 +48,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
@@ -1,47 +0,0 @@
|
||||
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 64 MiB database limit into Gradle properties", () => {
|
||||
it("writes a 10 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" }, 64);
|
||||
const config = plugin({ name: "Paseo", slug: "paseo" }, 10);
|
||||
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: "64" },
|
||||
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,7 +127,6 @@ 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,
|
||||
@@ -271,7 +270,6 @@ 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,7 +41,6 @@ function KnownHostRoute() {
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PluginSurfaceScreen } from "@/plugins";
|
||||
|
||||
export default function PluginSurfaceRoute() {
|
||||
return <PluginSurfaceScreen />;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import React from "react";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
@@ -87,13 +86,6 @@ 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"),
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
ForgeSearchItem,
|
||||
UploadedFileAttachment,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PluginResourceComposerAttachment } from "@/plugins/attachments";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
@@ -107,7 +106,6 @@ 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
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* @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,61 +34,6 @@ 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;
|
||||
@@ -147,28 +92,19 @@ 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,
|
||||
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,
|
||||
};
|
||||
|
||||
const wrapperProps = useDragHandle
|
||||
|
||||
@@ -75,7 +75,6 @@ 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"];
|
||||
|
||||
@@ -682,7 +681,6 @@ function MobileSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<Pressable
|
||||
@@ -901,7 +899,6 @@ function DesktopSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ProjectEditSheetProps {
|
||||
export function ProjectEditSheet({
|
||||
visible,
|
||||
onClose,
|
||||
serverId,
|
||||
projectId,
|
||||
projectViewKey,
|
||||
client,
|
||||
@@ -59,6 +60,7 @@ 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,7 +33,6 @@ 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";
|
||||
@@ -617,7 +616,6 @@ function ProjectMenuItems({
|
||||
|
||||
function WorkspaceRowRightGroup({
|
||||
workspace,
|
||||
backdrop,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
isCreating,
|
||||
@@ -636,7 +634,6 @@ function WorkspaceRowRightGroup({
|
||||
onTogglePin,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
isCreating: boolean;
|
||||
@@ -684,10 +681,7 @@ function WorkspaceRowRightGroup({
|
||||
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
scrimBackdrop={showScrim ? backdrop : undefined}
|
||||
>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
@@ -1130,7 +1124,6 @@ function WorkspaceRowInner({
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
@@ -1175,7 +1168,7 @@ function WorkspaceRowInner({
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
@@ -1185,7 +1178,6 @@ function WorkspaceRowInner({
|
||||
>
|
||||
<WorkspaceRowRightGroup
|
||||
workspace={workspace}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
@@ -1949,30 +1941,6 @@ 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.
|
||||
@@ -1996,10 +1964,7 @@ export function SidebarWorkspaceList({
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
@@ -2020,7 +1985,6 @@ export function SidebarWorkspaceList({
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2037,10 +2001,7 @@ function SidebarStatusModeWrapper({
|
||||
hostBadgeByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
}: {
|
||||
statusGroups: StatusGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
@@ -2051,25 +2012,17 @@ 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={pinnedWorkspaces}
|
||||
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => {
|
||||
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
|
||||
return entry ? [entry] : [];
|
||||
})}
|
||||
projectIconByProjectViewKey={projectIconByProjectViewKey}
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
@@ -2077,10 +2030,7 @@ function SidebarStatusModeWrapper({
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2103,14 +2053,12 @@ 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();
|
||||
@@ -2348,14 +2296,10 @@ function ProjectModeList({
|
||||
);
|
||||
|
||||
const renderPinnedChat = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
(workspace: SidebarWorkspacePlacement) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
||||
@@ -2372,9 +2316,6 @@ function ProjectModeList({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -2400,20 +2341,7 @@ function ProjectModeList({
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
{visiblePinnedChats.map(renderPinnedChat)}
|
||||
{canTogglePinnedChats ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedChatsExpanded}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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";
|
||||
|
||||
@@ -42,7 +41,6 @@ export function SidebarModelProvider({
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
|
||||
const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder);
|
||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleProjectCollapsed,
|
||||
);
|
||||
@@ -60,7 +58,6 @@ export function SidebarModelProvider({
|
||||
buildSidebarProjection({
|
||||
projects: list.projects,
|
||||
pinnedKeys,
|
||||
pinnedWorkspaceOrder,
|
||||
workspaceEntriesByKey: projectionWorkspaceEntriesByKey,
|
||||
projectNamesByViewKey: list.projectNamesByViewKey,
|
||||
groupMode,
|
||||
@@ -76,7 +73,6 @@ export function SidebarModelProvider({
|
||||
list.projects,
|
||||
pinnedCollapsed,
|
||||
pinnedKeys,
|
||||
pinnedWorkspaceOrder,
|
||||
projectionWorkspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -66,7 +66,6 @@ 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,7 +24,6 @@ export interface SidebarProjection {
|
||||
export function buildSidebarProjection(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
pinnedKeys: PinnedSidebarKeys;
|
||||
pinnedWorkspaceOrder: string[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByViewKey: Map<string, string>;
|
||||
groupMode: SidebarGroupMode;
|
||||
@@ -35,7 +34,6 @@ 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,23 +1,7 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, Pressable, ScrollView, 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";
|
||||
@@ -26,7 +10,6 @@ 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,
|
||||
@@ -76,9 +59,6 @@ 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) => ({
|
||||
@@ -105,12 +85,6 @@ 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[];
|
||||
@@ -121,10 +95,7 @@ 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({
|
||||
@@ -137,10 +108,7 @@ export function SidebarStatusWorkspaceList({
|
||||
hostBadgeByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
@@ -156,44 +124,7 @@ export function SidebarStatusWorkspaceList({
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||
|
||||
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 statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||
const content = (
|
||||
<>
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
@@ -201,18 +132,23 @@ export function SidebarStatusWorkspaceList({
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<DraggableList
|
||||
testID="sidebar-pinned-list"
|
||||
data={visiblePinnedWorkspaces}
|
||||
keyExtractor={statusWorkspaceKeyExtractor}
|
||||
renderItem={renderPinnedWorkspace}
|
||||
onDragEnd={onPinnedWorkspaceReorder}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedWorkspacesExpanded}
|
||||
@@ -487,9 +423,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onWorkspacePress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
@@ -506,9 +439,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
@@ -535,9 +465,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
onPress={handlePress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -555,9 +482,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
@@ -575,9 +499,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -699,9 +620,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
@@ -717,53 +635,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
function StatusWorkspaceRowInner({
|
||||
workspace,
|
||||
hostBadge,
|
||||
projectName,
|
||||
@@ -786,51 +658,47 @@ function StatusWorkspaceRowInnerContent({
|
||||
onTogglePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
dragInteraction,
|
||||
}: StatusWorkspaceRowInnerProps & {
|
||||
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
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 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]);
|
||||
const handlePressIn = useCallback(() => setIsPressed(true), []);
|
||||
const handlePressOut = useCallback(() => setIsPressed(false), []);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
@@ -852,17 +720,9 @@ function StatusWorkspaceRowInnerContent({
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
isDragging,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
>
|
||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
@@ -889,9 +749,8 @@ function StatusWorkspaceRowInnerContent({
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handlePressIn}
|
||||
onTouchMove={moveDragPress}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={handlePress}
|
||||
onPress={onPress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
@@ -900,7 +759,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
leadingProjectName={projectName}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -910,7 +769,6 @@ function StatusWorkspaceRowInnerContent({
|
||||
{renderSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
backdrop={backdrop}
|
||||
trailing={trailing}
|
||||
showBase={showTrailing}
|
||||
showKebab={showKebabInSlot}
|
||||
@@ -940,7 +798,6 @@ function StatusWorkspaceRowInnerContent({
|
||||
|
||||
function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
backdrop,
|
||||
trailing,
|
||||
showBase,
|
||||
showKebab,
|
||||
@@ -959,7 +816,6 @@ function StatusWorkspaceActionSlot({
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
showBase: boolean;
|
||||
showKebab: boolean;
|
||||
@@ -983,10 +839,7 @@ function StatusWorkspaceActionSlot({
|
||||
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
scrimBackdrop={showScrim ? backdrop : undefined}
|
||||
>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
|
||||
{kebab.showKebab && onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
@@ -1014,20 +867,17 @@ 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,
|
||||
];
|
||||
}
|
||||
@@ -1122,14 +972,6 @@ 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,11 +70,7 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col
|
||||
|
||||
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||
|
||||
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 }),
|
||||
};
|
||||
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
@@ -113,7 +109,7 @@ export function SidebarWorkspaceRowFrame({
|
||||
disabled={contextMenuOpen}
|
||||
>
|
||||
{children({
|
||||
isHovered: isHovered && !contextMenuOpen && !isDragging,
|
||||
isHovered: isHovered && !contextMenuOpen,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange: handleContextMenuOpenChange,
|
||||
hoverHandlers,
|
||||
@@ -462,18 +458,18 @@ export function SidebarWorkspaceTrailingActionBase({
|
||||
|
||||
export function SidebarWorkspaceTrailingActionOverlay({
|
||||
visible,
|
||||
scrimBackdrop,
|
||||
scrim = false,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
|
||||
scrimBackdrop?: SidebarSurfaceBackdrop;
|
||||
scrim?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!visible || !children) return null;
|
||||
return (
|
||||
<>
|
||||
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
|
||||
{scrim ? <TrailingActionScrim /> : null}
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
|
||||
</>
|
||||
);
|
||||
@@ -488,21 +484,14 @@ 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({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) {
|
||||
function TrailingActionScrim() {
|
||||
// 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"
|
||||
testID="sidebar-workspace-trailing-scrim"
|
||||
>
|
||||
<ThemedTrailingActionScrimSvg
|
||||
gradientId={gradientId}
|
||||
uniProps={scrimColorMappings[backdrop]}
|
||||
/>
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionScrim} pointerEvents="none">
|
||||
<ThemedTrailingActionScrimSvg gradientId={gradientId} uniProps={scrimColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -311,7 +310,6 @@ function WorkspaceRowBody({
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...(draggable ? dragAttributes : {})}
|
||||
@@ -355,7 +353,7 @@ function WorkspaceRowBody({
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
@@ -364,7 +362,6 @@ function WorkspaceRowBody({
|
||||
>
|
||||
<WorkspaceRowTrailingActions
|
||||
workspace={workspace}
|
||||
backdrop={backdrop}
|
||||
trailing={trailing}
|
||||
isHovered={isHovered}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
@@ -392,7 +389,6 @@ function WorkspaceRowBody({
|
||||
|
||||
function WorkspaceRowTrailingActions({
|
||||
workspace,
|
||||
backdrop,
|
||||
trailing,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
@@ -410,7 +406,6 @@ function WorkspaceRowTrailingActions({
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
@@ -455,10 +450,7 @@ function WorkspaceRowTrailingActions({
|
||||
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
scrimBackdrop={showScrim ? backdrop : undefined}
|
||||
>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
{...kebab.menuProps}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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);
|
||||
@@ -33,20 +32,20 @@ export function useLongPressDragInteraction(input: {
|
||||
}, []);
|
||||
|
||||
const openContextMenuAtStartPoint = useCallback(() => {
|
||||
if (!menuController || !touchStartRef.current) {
|
||||
if (!input.menuController || !touchStartRef.current) {
|
||||
return;
|
||||
}
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
menuController.setAnchorRect({
|
||||
input.menuController.setAnchorRect({
|
||||
x: touchStartRef.current.x,
|
||||
y: touchStartRef.current.y + statusBarHeight,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
menuController.setOpen(true);
|
||||
input.menuController.setOpen(true);
|
||||
menuOpenedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
}, [menuController]);
|
||||
}, [input.menuController]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
// Manual timers own long-press behavior on mobile.
|
||||
@@ -88,10 +87,10 @@ export function useLongPressDragInteraction(input: {
|
||||
dragActivatedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
drag();
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!menuController) {
|
||||
if (!input.menuController) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,7 +112,7 @@ export function useLongPressDragInteraction(input: {
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
openContextMenuAtStartPoint();
|
||||
}, CONTEXT_MENU_DELAY_MS);
|
||||
}, [clearTimers, drag, menuController, openContextMenuAtStartPoint]);
|
||||
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
|
||||
@@ -438,8 +438,7 @@ export function WorkspaceSetupDialog() {
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacementKey={chatDraft.textReplacementKey}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
|
||||
@@ -863,35 +863,6 @@ 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,7 +10,6 @@ import {
|
||||
buildLegacyGitHubAttachmentFromSearchItem,
|
||||
} from "@/utils/review-attachments";
|
||||
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments";
|
||||
|
||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||
|
||||
@@ -56,11 +55,6 @@ 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,25 +224,11 @@ describe("useAgentInputDraft live contract", () => {
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
const hydratedReplacementKey = getLatest().textReplacementKey;
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("hello world");
|
||||
getLatest().setText("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();
|
||||
});
|
||||
@@ -432,7 +418,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("with attachment");
|
||||
getLatest().setText("with attachment");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||
});
|
||||
|
||||
@@ -514,7 +500,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("queued message");
|
||||
getLatest().setText("queued message");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||
});
|
||||
|
||||
@@ -568,7 +554,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("queued message");
|
||||
getLatest().setText("queued message");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
|
||||
});
|
||||
|
||||
@@ -584,7 +570,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("draft again");
|
||||
getLatest().setText("draft again");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -52,9 +52,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
|
||||
export interface AgentInputDraft {
|
||||
text: string;
|
||||
editText: (text: string) => void;
|
||||
replaceText: (text: string) => void;
|
||||
textReplacementKey: string;
|
||||
setText: (text: string) => void;
|
||||
attachments: UserComposerAttachment[];
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
@@ -86,7 +84,6 @@ 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;
|
||||
@@ -110,21 +107,13 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const editText = useCallback(
|
||||
const setText = 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) => ({
|
||||
@@ -147,7 +136,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
void (async () => {
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
setTextReplacementRevision((revision) => revision + 1);
|
||||
setHydratedDraftKey(draftKey);
|
||||
}
|
||||
})();
|
||||
@@ -276,9 +264,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
return {
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey: `${draftKey}:${textReplacementRevision}`,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
|
||||
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const draftOnSetFeature = composerState.agentControls.onSetFeature;
|
||||
|
||||
const clearDraftInput = draftInput.clear;
|
||||
const replaceDraftText = draftInput.replaceText;
|
||||
const setDraftText = draftInput.setText;
|
||||
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;
|
||||
replaceDraftText("");
|
||||
setDraftText("");
|
||||
setDraftAttachments([]);
|
||||
const preparedAttempt =
|
||||
initialCreateAttempt?.clientMessageId === submission.clientMessageId
|
||||
@@ -604,7 +604,7 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd: submission.cwd,
|
||||
});
|
||||
void createPromise.catch(() => {
|
||||
replaceDraftText(submission.text);
|
||||
setDraftText(submission.text);
|
||||
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
|
||||
autoSubmitKeyRef.current = null;
|
||||
});
|
||||
@@ -617,7 +617,7 @@ export function WorkspaceDraftAgentTab({
|
||||
isReadyForPendingAutoSubmit,
|
||||
serverId,
|
||||
setDraftAttachments,
|
||||
replaceDraftText,
|
||||
setDraftText,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
@@ -699,8 +699,7 @@ export function WorkspaceDraftAgentTab({
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacementKey={draftInput.textReplacementKey}
|
||||
onChangeText={draftInput.setText}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
|
||||
@@ -125,8 +125,6 @@ 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,
|
||||
@@ -442,20 +440,6 @@ 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}`}
|
||||
@@ -862,7 +846,6 @@ interface ComposerProps {
|
||||
blurOnSubmit?: boolean;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
textReplacementKey: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
attachmentScopeKeys?: readonly string[];
|
||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||
@@ -1071,7 +1054,6 @@ export function Composer({
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
onChangeText,
|
||||
textReplacementKey,
|
||||
attachments,
|
||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||
onOpenWorkspaceAttachment,
|
||||
@@ -1169,7 +1151,6 @@ 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);
|
||||
@@ -1177,30 +1158,11 @@ 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) {
|
||||
@@ -1211,7 +1173,7 @@ export function Composer({
|
||||
messageInputRef.current?.blur();
|
||||
}
|
||||
clearDraft("sent");
|
||||
replaceUserInput("");
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
resetSuppression();
|
||||
setSendError(null);
|
||||
@@ -1232,14 +1194,14 @@ export function Composer({
|
||||
onClientSlashCommand,
|
||||
resetSuppression,
|
||||
setSelectedAttachments,
|
||||
replaceUserInput,
|
||||
setUserInput,
|
||||
],
|
||||
);
|
||||
|
||||
const autocomplete = useAgentAutocomplete({
|
||||
userInput,
|
||||
cursorIndex,
|
||||
setUserInput: replaceUserInput,
|
||||
setUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig: commandDraftConfig,
|
||||
@@ -1394,7 +1356,7 @@ export function Composer({
|
||||
});
|
||||
if (!result.queued) return;
|
||||
|
||||
replaceUserInput("");
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
resetSuppression();
|
||||
clearSentAttachments(queuedAttachments);
|
||||
@@ -1405,7 +1367,7 @@ export function Composer({
|
||||
queueWriter,
|
||||
resetSuppression,
|
||||
setSelectedAttachments,
|
||||
replaceUserInput,
|
||||
setUserInput,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1436,7 +1398,7 @@ export function Composer({
|
||||
await submitMessage(submitText, submitAttachments);
|
||||
},
|
||||
clearDraft,
|
||||
setUserInput: replaceUserInput,
|
||||
setUserInput,
|
||||
setAttachments: (nextAttachments) => {
|
||||
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
|
||||
},
|
||||
@@ -1461,7 +1423,7 @@ export function Composer({
|
||||
isAgentRunning,
|
||||
queueMessage,
|
||||
setSelectedAttachments,
|
||||
replaceUserInput,
|
||||
setUserInput,
|
||||
submitBehavior,
|
||||
submitMessage,
|
||||
t,
|
||||
@@ -1522,30 +1484,6 @@ 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;
|
||||
@@ -1748,10 +1686,10 @@ export function Composer({
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result) return;
|
||||
replaceUserInput(result.text);
|
||||
setUserInput(result.text);
|
||||
setSelectedAttachments(result.attachments);
|
||||
},
|
||||
[agentId, queueWriter, replaceUserInput, setSelectedAttachments],
|
||||
[agentId, queueWriter, setSelectedAttachments, setUserInput],
|
||||
);
|
||||
|
||||
const handleSendQueuedNow = useCallback(
|
||||
@@ -1984,7 +1922,6 @@ export function Composer({
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
},
|
||||
...pluginAttachments.menuItems,
|
||||
{
|
||||
id: "file",
|
||||
label: t("composer.attachments.addFile"),
|
||||
@@ -1995,14 +1932,7 @@ export function Composer({
|
||||
},
|
||||
);
|
||||
return items;
|
||||
}, [
|
||||
forgePresentation,
|
||||
handlePasteImage,
|
||||
handlePickFile,
|
||||
handlePickImage,
|
||||
pluginAttachments.menuItems,
|
||||
t,
|
||||
]);
|
||||
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
(item: ForgeSearchItem) => {
|
||||
@@ -2143,8 +2073,7 @@ export function Composer({
|
||||
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
|
||||
const isSubmitLoadingVisible =
|
||||
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
|
||||
const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile;
|
||||
const isSubmitDisabled =
|
||||
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
|
||||
@@ -2213,7 +2142,6 @@ export function Composer({
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
onPasteImages={handleNativePasteImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
@@ -2239,7 +2167,6 @@ export function Composer({
|
||||
attachmentSlot={attachmentTray}
|
||||
inputMode={inputMode}
|
||||
readOnly={readOnly}
|
||||
textReplacementKey={textReplacementKey}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
<Combobox
|
||||
@@ -2262,7 +2189,6 @@ export function Composer({
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{pluginAttachments.picker}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -56,9 +56,6 @@ 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,
|
||||
@@ -108,7 +105,6 @@ 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;
|
||||
@@ -151,8 +147,6 @@ 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;
|
||||
}
|
||||
@@ -160,8 +154,6 @@ 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.
|
||||
@@ -405,9 +397,12 @@ function handleDesktopKeyPressImpl(
|
||||
ctx.handleDefaultSendAction();
|
||||
}
|
||||
|
||||
function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTMLElement | null {
|
||||
function getTextInputNativeElement(
|
||||
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
||||
): HTMLElement | null {
|
||||
if (!current) return null;
|
||||
const native = typeof current.getNativeRef === "function" ? current.getNativeRef() : current;
|
||||
const handle = current as TextInput & { getNativeRef?: () => unknown };
|
||||
const native = typeof handle.getNativeRef === "function" ? handle.getNativeRef() : current;
|
||||
return native instanceof HTMLElement ? native : null;
|
||||
}
|
||||
|
||||
@@ -483,7 +478,9 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
}
|
||||
|
||||
function useAutoFocusOnWebEffect(
|
||||
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>,
|
||||
textInputRef: React.MutableRefObject<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
>,
|
||||
autoFocus: boolean,
|
||||
autoFocusKey: string | undefined,
|
||||
): void {
|
||||
@@ -591,8 +588,8 @@ function FocusHint({
|
||||
interface ComposerTextSurfaceProps {
|
||||
readOnly: boolean;
|
||||
value: string;
|
||||
textInputRef: React.Ref<ComposerTextInputHandle>;
|
||||
textInputStyle: React.ComponentProps<typeof TextInput>["style"];
|
||||
textInputRef: React.Ref<TextInput>;
|
||||
textInputStyle: React.ComponentProps<typeof ThemedTextInput>["style"];
|
||||
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
|
||||
placeholder: string;
|
||||
accessibilityLabel: string;
|
||||
@@ -605,8 +602,6 @@ 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;
|
||||
@@ -629,12 +624,13 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
}
|
||||
return (
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<ComposerTextInput
|
||||
<ThemedTextInput
|
||||
ref={props.textInputRef}
|
||||
dataSet={COMPOSER_INPUT_DATASET}
|
||||
text={props.value}
|
||||
value={props.value}
|
||||
onChangeText={props.onChangeText}
|
||||
placeholder={props.placeholder}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
@@ -645,8 +641,6 @@ 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
|
||||
@@ -910,7 +904,7 @@ interface QueueMessageContext {
|
||||
attachments: ComposerAttachment[];
|
||||
cwd: string;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
replaceText: (text: string) => void;
|
||||
onChangeText: (text: string) => void;
|
||||
onMinimizeHeight: () => void;
|
||||
}
|
||||
|
||||
@@ -919,7 +913,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.replaceText("");
|
||||
ctx.onChangeText("");
|
||||
ctx.onMinimizeHeight();
|
||||
}
|
||||
|
||||
@@ -971,7 +965,9 @@ function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||
}
|
||||
|
||||
function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHandle | null {
|
||||
function getWebTextAreaImpl(
|
||||
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
||||
): TextAreaHandle | null {
|
||||
if (!current) return null;
|
||||
const candidate = current as { getNativeRef?: () => unknown };
|
||||
if (typeof candidate.getNativeRef === "function") {
|
||||
@@ -1023,7 +1019,6 @@ 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;
|
||||
@@ -1049,7 +1044,6 @@ interface ResolvedMessageInputProps {
|
||||
attachmentSlot: React.ReactNode;
|
||||
inputMode: ComposerInputMode;
|
||||
readOnly: boolean;
|
||||
textReplacementKey: string;
|
||||
submitLabel: string | undefined;
|
||||
}
|
||||
|
||||
@@ -1071,7 +1065,6 @@ 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,
|
||||
@@ -1097,7 +1090,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
attachmentSlot: props.attachmentSlot,
|
||||
inputMode: props.inputMode ?? "chat",
|
||||
readOnly: props.readOnly ?? false,
|
||||
textReplacementKey: props.textReplacementKey,
|
||||
submitLabel: props.submitLabel,
|
||||
};
|
||||
}
|
||||
@@ -1127,7 +1119,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
attachmentMenuItems,
|
||||
onAttachButtonRef,
|
||||
onAddImages,
|
||||
onPasteImages,
|
||||
client,
|
||||
isReadyForDictation,
|
||||
placeholder,
|
||||
@@ -1153,7 +1144,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
attachmentSlot,
|
||||
inputMode,
|
||||
readOnly,
|
||||
textReplacementKey,
|
||||
submitLabel,
|
||||
} = resolveMessageInputProps(props);
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
@@ -1171,29 +1161,18 @@ 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<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],
|
||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
||||
null,
|
||||
);
|
||||
const isInputFocusedRef = useRef(false);
|
||||
|
||||
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(),
|
||||
@@ -1212,6 +1191,7 @@ 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) => {
|
||||
@@ -1225,11 +1205,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
|
||||
appliedTextReplacementKeyRef.current = textReplacementKey;
|
||||
valueRef.current = value;
|
||||
textInputRef.current?.replaceText(value);
|
||||
}, [textReplacementKey, value]);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1249,13 +1226,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
onSubmit,
|
||||
replaceText,
|
||||
onChangeText,
|
||||
attachments,
|
||||
cwd,
|
||||
autoSend,
|
||||
});
|
||||
},
|
||||
[replaceText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||
);
|
||||
|
||||
const handleDictationError = useCallback(
|
||||
@@ -1436,7 +1413,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const handleSendMessage = useCallback(
|
||||
() =>
|
||||
sendMessageImpl({
|
||||
value: textInputRef.current?.getText() ?? valueRef.current,
|
||||
value: valueRef.current,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
@@ -1461,14 +1438,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const handleQueueMessage = useCallback(
|
||||
() =>
|
||||
queueMessageImpl({
|
||||
value: textInputRef.current?.getText() ?? valueRef.current,
|
||||
value: valueRef.current,
|
||||
attachments,
|
||||
cwd,
|
||||
onQueue,
|
||||
replaceText,
|
||||
onChangeText,
|
||||
onMinimizeHeight: minimizeInputHeight,
|
||||
}),
|
||||
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
|
||||
[attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
|
||||
);
|
||||
|
||||
const handleDefaultSendAction = useCallback(() => {
|
||||
@@ -1641,14 +1618,6 @@ 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,
|
||||
@@ -1763,8 +1732,6 @@ 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", {
|
||||
@@ -2015,7 +1982,11 @@ 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,
|
||||
replaceText: (text) => actions.push(`replace:${text}`),
|
||||
onChangeText: (text) => actions.push(`change:${text}`),
|
||||
onSubmit: (payload) => actions.push(`submit:${payload.text}`),
|
||||
attachments: [],
|
||||
cwd: "/repo",
|
||||
@@ -181,7 +181,7 @@ describe("dictation transcript behavior", () => {
|
||||
});
|
||||
|
||||
expect(actions).toEqual([
|
||||
"replace:typed context spoken prompt",
|
||||
"change:typed context spoken prompt",
|
||||
"submit:typed context spoken prompt",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ interface DictationTranscriptContext {
|
||||
isAgentRunning: boolean;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
onSubmit: (payload: MessagePayload) => void;
|
||||
replaceText: (text: string) => void;
|
||||
onChangeText: (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.replaceText(nextValue);
|
||||
ctx.onChangeText(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.replaceText(nextValue);
|
||||
ctx.onChangeText(nextValue);
|
||||
|
||||
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
|
||||
ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd });
|
||||
ctx.replaceText("");
|
||||
ctx.onChangeText("");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export * from "./text-input.native";
|
||||
@@ -1,72 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,148 +0,0 @@
|
||||
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(["你好"]);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -19,10 +19,7 @@ import {
|
||||
type TimelineReducerSideEffect,
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
isTimelineResumeSnapshotAuthoritative,
|
||||
planTimelineTailFetch,
|
||||
} from "@/timeline/timeline-sync-plan";
|
||||
import { isTimelineResumeSnapshotAuthoritative } from "@/timeline/timeline-sync-plan";
|
||||
import {
|
||||
createViewedTimelineSync,
|
||||
type TimelineDeliveryMode,
|
||||
@@ -36,7 +33,6 @@ 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";
|
||||
@@ -71,20 +67,6 @@ 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 {
|
||||
@@ -368,7 +350,6 @@ 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());
|
||||
@@ -547,11 +528,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const applyTimelineResponse = useCallback(
|
||||
(receivedPayload: TimelineResponsePayload) => {
|
||||
const payload = consumeForcedTimelineTailReplacement(
|
||||
receivedPayload,
|
||||
forcedTimelineTailReplacements.current,
|
||||
);
|
||||
(
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agent_timeline_response" }
|
||||
>["payload"],
|
||||
) => {
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldMarkAuthoritativeHistoryApplied = isTimelineResumeSnapshotAuthoritative({
|
||||
@@ -660,15 +642,6 @@ 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);
|
||||
@@ -699,18 +672,6 @@ 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 });
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useQueries,
|
||||
useQuery,
|
||||
type QueryKey,
|
||||
type QueryClient,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
@@ -48,11 +47,8 @@ export function useFetchQuery<
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> {
|
||||
return useQuery(fetchQueryOptions(input), queryClient);
|
||||
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||
return useQuery(fetchQueryOptions(input));
|
||||
}
|
||||
|
||||
export function useFetchQueries<TData>(
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
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 === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : '"';
|
||||
});
|
||||
}
|
||||
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"><' + escapeHtml(comp.componentName) + '></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,6 +59,7 @@ import {
|
||||
} from "@/desktop/browser/store";
|
||||
import {
|
||||
applyInactiveBrowserWebviewViewport,
|
||||
isResidentBrowserWebviewReady,
|
||||
prepareBrowserWebview,
|
||||
presentBrowserWebview,
|
||||
rememberBrowserWebviewSize,
|
||||
@@ -66,11 +67,6 @@ import {
|
||||
removeResidentBrowserWebview,
|
||||
takeResidentBrowserWebview,
|
||||
} from "../resident-webviews";
|
||||
import {
|
||||
createElementSelectorController,
|
||||
type BrowserElementSelection,
|
||||
type ElementSelectorOutcome,
|
||||
} from "./element-selector.electron";
|
||||
|
||||
type ElectronWebview = HTMLElement & {
|
||||
canGoBack?: () => boolean;
|
||||
@@ -81,7 +77,6 @@ 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;
|
||||
@@ -92,6 +87,10 @@ type WebTextInput = TextInput & {
|
||||
getNativeRef?: () => unknown;
|
||||
};
|
||||
|
||||
type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & {
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
|
||||
interface BrowserElementAnnotation {
|
||||
comment: string;
|
||||
}
|
||||
@@ -316,6 +315,20 @@ 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;
|
||||
@@ -424,6 +437,38 @@ 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,
|
||||
@@ -610,15 +655,13 @@ 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;
|
||||
@@ -694,7 +737,7 @@ export function BrowserPane({
|
||||
|
||||
const syncNavigationState = useCallback((input?: { syncUrl?: boolean }) => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) {
|
||||
if (!webview || !domReadyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -733,6 +776,7 @@ 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,
|
||||
@@ -764,7 +808,7 @@ export function BrowserPane({
|
||||
});
|
||||
|
||||
const handleStartLoading = () => {
|
||||
selectorControllerRef.current?.stopForWebview(webview);
|
||||
domReadyRef.current = false;
|
||||
updateBrowser(browserId, { isLoading: true, lastError: null });
|
||||
syncNavigationState({ syncUrl: false });
|
||||
};
|
||||
@@ -831,6 +875,7 @@ 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.
|
||||
@@ -895,10 +940,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]);
|
||||
@@ -1151,8 +1196,8 @@ export function BrowserPane({
|
||||
);
|
||||
|
||||
const handleSelectorResult = useCallback(
|
||||
(selection: BrowserElementSelection, mode: "annotate" | "screenshot") => {
|
||||
if (mode === "screenshot") {
|
||||
(selection: BrowserElementSelection) => {
|
||||
if (selectorModeRef.current === "screenshot") {
|
||||
void screenshotElementToClipboard(selection);
|
||||
return;
|
||||
}
|
||||
@@ -1185,62 +1230,271 @@ 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;
|
||||
const controller = selectorControllerRef.current;
|
||||
if (!webview || !controller) {
|
||||
showSelectorFailure("unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const startResult = controller.start({
|
||||
webview,
|
||||
mode,
|
||||
onFinish: handleElementSelectorOutcome,
|
||||
});
|
||||
if (startResult !== "started") {
|
||||
showSelectorFailure(startResult);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webview || !domReadyRef.current) return;
|
||||
// Annotate needs a workspace scope to attach to; screenshot only copies.
|
||||
if (mode === "annotate" && !workspaceAttachmentScopeKey) return;
|
||||
selectorModeRef.current = mode;
|
||||
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 === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : '"';
|
||||
});
|
||||
}
|
||||
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"><' + escapeHtml(comp.componentName) + '></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);
|
||||
}
|
||||
},
|
||||
[handleElementSelectorOutcome, showSelectorFailure, workspaceAttachmentScopeKey],
|
||||
[handleSelectorResult, workspaceAttachmentScopeKey],
|
||||
);
|
||||
|
||||
const cancelElementSelector = useCallback(() => {
|
||||
selectorControllerRef.current?.cancel();
|
||||
const webview = webviewRef.current;
|
||||
setSelectorMode(null);
|
||||
if (webview && domReadyRef.current) {
|
||||
try {
|
||||
clearWebviewSelector(webview);
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const currentPageUrl = browser?.url ?? null;
|
||||
@@ -1272,7 +1526,7 @@ export function BrowserPane({
|
||||
return;
|
||||
}
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) {
|
||||
if (!webview || !domReadyRef.current) {
|
||||
return;
|
||||
}
|
||||
if (annotationMarkers.length === 0) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
getResidentBrowserWebview,
|
||||
isResidentBrowserWebviewReady,
|
||||
prepareBrowserWebview,
|
||||
presentBrowserWebview,
|
||||
rememberBrowserWebviewSize,
|
||||
@@ -204,6 +205,28 @@ 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,6 +15,7 @@ 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;
|
||||
@@ -74,6 +75,15 @@ 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;
|
||||
@@ -312,6 +322,11 @@ 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: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
memo,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
@@ -44,12 +43,6 @@ 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 {
|
||||
@@ -111,7 +104,6 @@ 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,
|
||||
@@ -188,7 +180,7 @@ function HighlightedToken({ token }: { token: HighlightToken }) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
const HighlightedText = memo(function HighlightedText({
|
||||
function HighlightedText({
|
||||
tokens,
|
||||
textMetricsStyle,
|
||||
wrapLines = false,
|
||||
@@ -204,14 +196,10 @@ const HighlightedText = memo(function HighlightedText({
|
||||
[textMetricsStyle, wrapLines],
|
||||
);
|
||||
|
||||
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]);
|
||||
const keyedTokens = useMemo(
|
||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||
[tokens],
|
||||
);
|
||||
|
||||
return (
|
||||
<Text style={containerStyle} testID={testID}>
|
||||
@@ -220,7 +208,7 @@ const HighlightedText = memo(function HighlightedText({
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
@@ -816,53 +804,8 @@ 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,
|
||||
@@ -871,7 +814,6 @@ function SplitDiffColumn({
|
||||
showDivider = false,
|
||||
}: {
|
||||
rows: SplitDiffRow[];
|
||||
rowWindow: DiffRowWindow;
|
||||
side: "left" | "right";
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
@@ -902,20 +844,12 @@ function SplitDiffColumn({
|
||||
[textMetricsStyle],
|
||||
);
|
||||
|
||||
const keyedRows = useMemo(
|
||||
() =>
|
||||
rows.slice(rowWindow.startIndex, rowWindow.endIndex).map((row, index) => ({
|
||||
key: `row-${rowWindow.startIndex + index}`,
|
||||
row,
|
||||
})),
|
||||
[rowWindow.endIndex, rowWindow.startIndex, rows],
|
||||
);
|
||||
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
|
||||
|
||||
if (wrapLines) {
|
||||
return (
|
||||
<View style={wrapCellStyle}>
|
||||
<View style={styles.linesContainer}>
|
||||
<DiffRowSpacer height={rowWindow.beforeHeight} />
|
||||
{keyedRows.map(({ key, row }) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
@@ -948,7 +882,6 @@ function SplitDiffColumn({
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<DiffRowSpacer height={rowWindow.afterHeight} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -957,7 +890,6 @@ function SplitDiffColumn({
|
||||
return (
|
||||
<View style={rowCellStyle}>
|
||||
<View style={styles.gutterColumn}>
|
||||
<DiffRowSpacer height={rowWindow.beforeHeight} />
|
||||
{keyedRows.map(({ key, row }) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
@@ -999,7 +931,6 @@ function SplitDiffColumn({
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<DiffRowSpacer height={rowWindow.afterHeight} />
|
||||
</View>
|
||||
<DiffScroll
|
||||
scrollViewWidth={scrollWidth}
|
||||
@@ -1008,7 +939,6 @@ function SplitDiffColumn({
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
>
|
||||
<View style={linesContainerRowStyle}>
|
||||
<DiffRowSpacer height={rowWindow.beforeHeight} />
|
||||
{keyedRows.map(({ key, row }) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
@@ -1044,7 +974,6 @@ function SplitDiffColumn({
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<DiffRowSpacer height={rowWindow.afterHeight} />
|
||||
</View>
|
||||
</DiffScroll>
|
||||
</View>
|
||||
@@ -1326,8 +1255,6 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
export function DiffFileBody({
|
||||
file,
|
||||
viewport,
|
||||
bodyOffset = 0,
|
||||
layout,
|
||||
wrapLines,
|
||||
codeFontSize,
|
||||
@@ -1337,8 +1264,6 @@ export function DiffFileBody({
|
||||
testID,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
viewport?: DiffViewport;
|
||||
bodyOffset?: number;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
codeFontSize: number;
|
||||
@@ -1351,50 +1276,6 @@ 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) => {
|
||||
@@ -1443,11 +1324,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={splitRows}
|
||||
rowWindow={rowWindow}
|
||||
rows={rows}
|
||||
side="left"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
@@ -1455,8 +1336,7 @@ export function DiffFileBody({
|
||||
reviewActions={reviewActions}
|
||||
/>
|
||||
<SplitDiffColumn
|
||||
rows={splitRows}
|
||||
rowWindow={rowWindow}
|
||||
rows={rows}
|
||||
side="right"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
@@ -1468,32 +1348,30 @@ export function DiffFileBody({
|
||||
);
|
||||
}
|
||||
|
||||
const computedLines = buildUnifiedDiffLines(file);
|
||||
|
||||
if (wrapLines) {
|
||||
return (
|
||||
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
|
||||
<View style={styles.linesContainer}>
|
||||
<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} />
|
||||
{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>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -1504,33 +1382,28 @@ export function DiffFileBody({
|
||||
return (
|
||||
<View style={[styles.diffContent, styles.diffContentRow]} dataSet={CODE_SURFACE_DATASET}>
|
||||
<View style={styles.gutterColumn}>
|
||||
<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} />
|
||||
{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>
|
||||
))}
|
||||
</View>
|
||||
<DiffScroll
|
||||
scrollViewWidth={scrollViewWidth}
|
||||
@@ -1539,8 +1412,7 @@ export function DiffFileBody({
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
>
|
||||
<View style={linesContainerRowStyle}>
|
||||
<DiffRowSpacer height={rowWindow.beforeHeight} />
|
||||
{visibleUnifiedLines.map(({ entry: { line, key, reviewTarget }, index }) => (
|
||||
{computedLines.map(({ line, key, reviewTarget }, index) => (
|
||||
<View key={key} testID={`diff-code-row-${index}`}>
|
||||
<DiffTextLine
|
||||
line={line}
|
||||
@@ -1560,7 +1432,6 @@ export function DiffFileBody({
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
<DiffRowSpacer height={rowWindow.afterHeight} />
|
||||
</View>
|
||||
</DiffScroll>
|
||||
</View>
|
||||
@@ -1957,9 +1828,7 @@ const EMPTY_PATH_LIST: string[] = [];
|
||||
|
||||
interface DiffFileMetrics {
|
||||
contentLength: number;
|
||||
splitRows?: SplitDiffRow[];
|
||||
splitLineCount?: number;
|
||||
unifiedLines?: ReturnType<typeof buildUnifiedDiffLines>;
|
||||
unifiedLineCount: number;
|
||||
}
|
||||
|
||||
@@ -1986,23 +1855,11 @@ function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics {
|
||||
function getSplitDiffLineCount(file: ParsedDiffFile): number {
|
||||
const metrics = getDiffFileMetrics(file);
|
||||
if (metrics.splitLineCount === undefined) {
|
||||
metrics.splitLineCount = getCachedSplitDiffRows(file).length;
|
||||
metrics.splitLineCount = buildSplitDiffRows(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",
|
||||
@@ -2205,8 +2062,6 @@ 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);
|
||||
@@ -2361,13 +2216,9 @@ 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);
|
||||
},
|
||||
[diffViewport, updateScrollbarOffset],
|
||||
[updateScrollbarOffset],
|
||||
);
|
||||
|
||||
const handleDiffListLayout = useCallback(
|
||||
@@ -2377,10 +2228,9 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
diffViewport.update({ top: diffListScrollOffsetRef.current, height });
|
||||
updateScrollbarLayout(event);
|
||||
},
|
||||
[diffViewport, updateScrollbarLayout],
|
||||
[updateScrollbarLayout],
|
||||
);
|
||||
|
||||
const computeItemOffset = useCallback(
|
||||
@@ -2537,7 +2387,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item, index }: { item: DiffFlatItem; index: number }) => {
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
if (item.type === "folder") {
|
||||
return (
|
||||
<DiffFolderRow
|
||||
@@ -2591,8 +2441,6 @@ 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}
|
||||
@@ -2605,9 +2453,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
},
|
||||
[
|
||||
codeFontSize,
|
||||
diffViewport,
|
||||
flatItems,
|
||||
getFlatItemHeight,
|
||||
handleBodyHeightChange,
|
||||
handleFolderRowHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
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("catches up after loaded authoritative history", () => {
|
||||
it("requests a bounded projected tail when authoritative history is loaded", () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
@@ -74,14 +74,13 @@ describe("ensureAgentIsInitialized", () => {
|
||||
serverId,
|
||||
agentId,
|
||||
request: {
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("requests a bounded projected tail when no authoritative cursor is available", () => {
|
||||
@@ -128,8 +127,6 @@ describe("ensureAgentIsInitialized", () => {
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
range: null,
|
||||
hasOlder: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -154,52 +151,6 @@ 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 { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import { 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 { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
|
||||
@@ -51,10 +51,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
|
||||
const timelineRequest = planTimelineResumeFetch(
|
||||
timeline.status === "synced" ? timeline.range : null,
|
||||
);
|
||||
const timelineRequest = planTimelineTailFetch();
|
||||
|
||||
const deferred = createInitDeferred(key, timelineRequest.direction);
|
||||
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
|
||||
|
||||
@@ -39,7 +39,6 @@ describe("splitPinnedSidebarGroups", () => {
|
||||
pinnedWorkspaceKeys: ["w1"],
|
||||
pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" },
|
||||
},
|
||||
pinnedWorkspaceOrder: [],
|
||||
});
|
||||
expect(result.pinnedChats).toHaveLength(1);
|
||||
expect(result.unpinnedProjects).toHaveLength(0);
|
||||
@@ -50,7 +49,6 @@ describe("splitPinnedSidebarGroups", () => {
|
||||
const result = splitPinnedSidebarGroups({
|
||||
projects,
|
||||
keys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} },
|
||||
pinnedWorkspaceOrder: [],
|
||||
});
|
||||
expect(result.unpinnedProjects).toHaveLength(1);
|
||||
});
|
||||
@@ -63,7 +61,6 @@ 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"]);
|
||||
@@ -80,7 +77,6 @@ describe("splitPinnedSidebarGroups", () => {
|
||||
newer: "2026-02-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
pinnedWorkspaceOrder: [],
|
||||
});
|
||||
|
||||
expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([
|
||||
@@ -88,26 +84,4 @@ 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 {
|
||||
@@ -102,9 +101,8 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid
|
||||
export function splitPinnedSidebarGroups(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
keys: PinnedSidebarKeys;
|
||||
pinnedWorkspaceOrder: string[];
|
||||
}): PinnedSidebarGroups {
|
||||
const { projects, keys, pinnedWorkspaceOrder } = input;
|
||||
const { projects, keys } = input;
|
||||
if (keys.pinnedWorkspaceKeys.length === 0) {
|
||||
return { pinnedChats: [], unpinnedProjects: projects };
|
||||
}
|
||||
@@ -140,12 +138,5 @@ export function splitPinnedSidebarGroups(input: {
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
pinnedChats: applyStoredOrdering({
|
||||
items: pinnedChats,
|
||||
storedOrder: pinnedWorkspaceOrder,
|
||||
getKey: (workspace) => workspace.workspaceKey,
|
||||
}),
|
||||
unpinnedProjects,
|
||||
};
|
||||
return { pinnedChats, unpinnedProjects };
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ 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,
|
||||
useWorkspaceDirectoryServerIds,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { useHydratedWorkspaceServerIds } 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";
|
||||
@@ -135,9 +132,8 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||
|
||||
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
|
||||
const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds);
|
||||
|
||||
const hostProjects = useHostProjects(directoryServerIds);
|
||||
const hostProjects = useHostProjects(hydratedServerIds);
|
||||
|
||||
const sidebarModel = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
common: {
|
||||
@@ -524,8 +523,6 @@ export const ar: TranslationResources = {
|
||||
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
|
||||
elementCopied: "تم نسخ العنصر إلى الحافظة",
|
||||
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
|
||||
selectorLoading: "انتظر حتى ينتهي تحميل الصفحة",
|
||||
selectorFailed: "تعذّر بدء محدد العنصر",
|
||||
},
|
||||
annotate: {
|
||||
title: "التعليق على العنصر",
|
||||
@@ -1618,7 +1615,6 @@ export const ar: TranslationResources = {
|
||||
archiveTooltip: "أرشفة الوكيل الفرعي",
|
||||
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
|
||||
archiveFinishedTooltip: "أرشفة المكتملين",
|
||||
archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1787,10 +1783,8 @@ export const ar: TranslationResources = {
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "نظرة عامة",
|
||||
},
|
||||
plugins: pluginSettings.ar,
|
||||
metadataGeneration: {
|
||||
title: "إنشاء البيانات الوصفية",
|
||||
description:
|
||||
|
||||
@@ -522,8 +522,6 @@ 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",
|
||||
@@ -1628,7 +1626,6 @@ export const en = {
|
||||
archiveTooltip: "Archive subagent",
|
||||
archiveFinishedAction: "Archive finished subagents",
|
||||
archiveFinishedTooltip: "Archive finished",
|
||||
archiveFinishedRetry: "Retry ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1797,56 +1794,8 @@ 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:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const es: TranslationResources = {
|
||||
common: {
|
||||
@@ -528,8 +527,6 @@ 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",
|
||||
@@ -1661,7 +1658,6 @@ export const es: TranslationResources = {
|
||||
archiveTooltip: "Subagente de archivo",
|
||||
archiveFinishedAction: "Archivar subagentes finalizados",
|
||||
archiveFinishedTooltip: "Archivar finalizados",
|
||||
archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1830,10 +1826,8 @@ export const es: TranslationResources = {
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "Resumen",
|
||||
},
|
||||
plugins: pluginSettings.es,
|
||||
metadataGeneration: {
|
||||
title: "Generación de metadatos",
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const fr: TranslationResources = {
|
||||
common: {
|
||||
@@ -528,8 +527,6 @@ 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",
|
||||
@@ -1665,7 +1662,6 @@ 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: {
|
||||
@@ -1834,10 +1830,8 @@ 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:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ja: TranslationResources = {
|
||||
common: {
|
||||
@@ -528,8 +527,6 @@ export const ja: TranslationResources = {
|
||||
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
|
||||
elementCopied: "要素をクリップボードにコピーしました",
|
||||
screenshotFailed: "スクリーンショットをコピーできませんでした",
|
||||
selectorLoading: "ページの読み込みが完了するまでお待ちください",
|
||||
selectorFailed: "要素セレクターを開始できませんでした",
|
||||
},
|
||||
annotate: {
|
||||
title: "要素に注釈を付ける",
|
||||
@@ -1634,7 +1631,6 @@ export const ja: TranslationResources = {
|
||||
archiveTooltip: "サブエージェントをアーカイブ",
|
||||
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
|
||||
archiveFinishedTooltip: "完了した項目をアーカイブ",
|
||||
archiveFinishedRetry: "再試行 ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1803,10 +1799,8 @@ export const ja: TranslationResources = {
|
||||
providers: "プロバイダー",
|
||||
usage: "使用状況",
|
||||
terminals: "ターミナル",
|
||||
plugins: "プラグイン",
|
||||
host: "概要",
|
||||
},
|
||||
plugins: pluginSettings.ja,
|
||||
metadataGeneration: {
|
||||
title: "メタデータ生成",
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ko: TranslationResources = {
|
||||
common: {
|
||||
@@ -525,8 +524,6 @@ export const ko: TranslationResources = {
|
||||
screenshotCopied: "스크린샷을 클립보드에 복사했습니다.",
|
||||
elementCopied: "요소를 클립보드에 복사했습니다.",
|
||||
screenshotFailed: "스크린샷을 복사할 수 없습니다.",
|
||||
selectorLoading: "페이지 로딩이 끝날 때까지 기다려 주세요.",
|
||||
selectorFailed: "요소 선택기를 시작할 수 없습니다.",
|
||||
},
|
||||
annotate: {
|
||||
title: "요소에 주석 달기",
|
||||
@@ -1628,7 +1625,6 @@ export const ko: TranslationResources = {
|
||||
archiveTooltip: "서브에이전트 보관",
|
||||
archiveFinishedAction: "완료된 하위 에이전트 보관",
|
||||
archiveFinishedTooltip: "아카이브 완료",
|
||||
archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1797,10 +1793,8 @@ export const ko: TranslationResources = {
|
||||
providers: "프로바이더",
|
||||
usage: "사용량",
|
||||
terminals: "터미널",
|
||||
plugins: "플러그인",
|
||||
host: "개요",
|
||||
},
|
||||
plugins: pluginSettings.ko,
|
||||
metadataGeneration: {
|
||||
title: "메타데이터 생성",
|
||||
description:
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
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;
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ptBR: TranslationResources = {
|
||||
common: {
|
||||
@@ -528,8 +527,6 @@ 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",
|
||||
@@ -1647,7 +1644,6 @@ export const ptBR: TranslationResources = {
|
||||
archiveTooltip: "Arquivar subagente",
|
||||
archiveFinishedAction: "Arquivar subagentes concluídos",
|
||||
archiveFinishedTooltip: "Arquivar concluídos",
|
||||
archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1816,10 +1812,8 @@ 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:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ru: TranslationResources = {
|
||||
common: {
|
||||
@@ -528,8 +527,6 @@ export const ru: TranslationResources = {
|
||||
screenshotCopied: "Снимок скопирован в буфер обмена",
|
||||
elementCopied: "Элемент скопирован в буфер обмена",
|
||||
screenshotFailed: "Не удалось скопировать снимок",
|
||||
selectorLoading: "Дождитесь окончания загрузки страницы",
|
||||
selectorFailed: "Не удалось запустить выбор элемента",
|
||||
},
|
||||
annotate: {
|
||||
title: "Аннотировать элемент",
|
||||
@@ -1652,7 +1649,6 @@ export const ru: TranslationResources = {
|
||||
archiveTooltip: "Архивный субагент",
|
||||
archiveFinishedAction: "Архивировать завершенные субагенты",
|
||||
archiveFinishedTooltip: "Архивировать завершенные",
|
||||
archiveFinishedRetry: "Повторить ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1821,10 +1817,8 @@ export const ru: TranslationResources = {
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "Обзор",
|
||||
},
|
||||
plugins: pluginSettings.ru,
|
||||
metadataGeneration: {
|
||||
title: "Создание метаданных",
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const zhCN: TranslationResources = {
|
||||
common: {
|
||||
@@ -524,8 +523,6 @@ export const zhCN: TranslationResources = {
|
||||
screenshotCopied: "已将截图复制到剪贴板",
|
||||
elementCopied: "已将元素复制到剪贴板",
|
||||
screenshotFailed: "无法复制截图",
|
||||
selectorLoading: "请等待页面加载完成",
|
||||
selectorFailed: "无法启动元素选择器",
|
||||
},
|
||||
annotate: {
|
||||
title: "标注元素",
|
||||
@@ -1598,7 +1595,6 @@ export const zhCN: TranslationResources = {
|
||||
archiveTooltip: "归档 subagent",
|
||||
archiveFinishedAction: "归档已完成的 subagent",
|
||||
archiveFinishedTooltip: "归档已完成项",
|
||||
archiveFinishedRetry: "重试 ({{failed}}/{{total}})",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
@@ -1767,10 +1763,8 @@ export const zhCN: TranslationResources = {
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
terminals: "Terminals",
|
||||
plugins: "插件",
|
||||
host: "概览",
|
||||
},
|
||||
plugins: pluginSettings["zh-CN"],
|
||||
metadataGeneration: {
|
||||
title: "元数据生成",
|
||||
description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
|
||||
|
||||
@@ -88,7 +88,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
useArchiveFinishedSubagents,
|
||||
useHideFinishedProviderSubagents,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentsForParent,
|
||||
@@ -1228,9 +1228,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
// when only toast state changes (which does not affect any draft field).
|
||||
const {
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1241,9 +1239,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const agentInputDraft = useMemo(
|
||||
(): AgentInputDraft => ({
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1253,9 +1249,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}),
|
||||
[
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1302,10 +1296,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
|
||||
|
||||
return (
|
||||
<RewindComposerRestoreProvider
|
||||
text={agentInputDraft.text}
|
||||
setText={agentInputDraft.replaceText}
|
||||
>
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<View style={styles.root}>
|
||||
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
@@ -1532,10 +1523,9 @@ function ActiveAgentComposer({
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
@@ -1629,8 +1619,7 @@ function ActiveAgentComposer({
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={handleHideFinishedProviderSubagents}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
<Composer
|
||||
@@ -1640,8 +1629,7 @@ function ActiveAgentComposer({
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.editText}
|
||||
textReplacementKey={agentInputDraft.textReplacementKey}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
attachments={agentInputDraft.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
PluginResourceComposerAttachmentSchema,
|
||||
pluginResourceAttachmentToAgentAttachment,
|
||||
type PluginResourceComposerAttachment,
|
||||
} from "./model";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user