mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6de842a3 | ||
|
|
2d97d04805 | ||
|
|
55c77021da | ||
|
|
03aedcdd47 | ||
|
|
869eb5a72f | ||
|
|
1a05491be5 | ||
|
|
28bb7e29be | ||
|
|
d6d04b0c56 | ||
|
|
20b2dad610 | ||
|
|
58ca9a9b30 | ||
|
|
dfa9977047 | ||
|
|
4f46e094a1 | ||
|
|
dd914c1479 | ||
|
|
025e7ef1d8 | ||
|
|
b4e837a9fe | ||
|
|
f098f77f4a | ||
|
|
f59ebd4fd5 | ||
|
|
aae97b2bdc |
@@ -3,7 +3,7 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Every base branch, so stacked PRs and PRs into any base get the same checks.
|
||||
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
||||
pull_request:
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -203,31 +203,6 @@
|
||||
"readFileSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Terminal child process: launcher readiness and PTY record ownership run outside the daemon.
|
||||
"terminal/terminal-worker-process.ts": [
|
||||
"chmodSync",
|
||||
"closeSync",
|
||||
"existsSync",
|
||||
"openSync",
|
||||
"rmSync",
|
||||
"writeSync"
|
||||
],
|
||||
// Worker startup only: choose and secure the local listening socket before accepting a controller.
|
||||
"terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"],
|
||||
// Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads.
|
||||
"terminal/terminal-worker-runtime.ts": [
|
||||
"chmodSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"statSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together.
|
||||
"terminal/paseo-cli-path.ts": ["existsSync"],
|
||||
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
|
||||
"terminal/terminal-ts-loader.mjs": ["existsSync"],
|
||||
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
||||
|
||||
Added:
|
||||
|
||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||
|
||||
Improved:
|
||||
|
||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
# 更新日志
|
||||
|
||||
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
||||
|
||||
新增:
|
||||
|
||||
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||
|
||||
改进:
|
||||
|
||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||
|
||||
@@ -66,7 +66,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
@@ -117,9 +116,10 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
|
||||
## Release branches
|
||||
|
||||
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
|
||||
release builds in a short-lived release workspace from `origin/main` or a release tag; see
|
||||
[release base and source](docs/release.md#release-base-and-source).
|
||||
When the user says "this goes to next", create or
|
||||
retarget the PR to `next` and preserve that destination through delivery. Follow
|
||||
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
||||
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
||||
|
||||
## Critical rules
|
||||
|
||||
|
||||
+2
-24
@@ -12,26 +12,6 @@ initializing → idle → running → idle (or error → closed)
|
||||
|
||||
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
|
||||
## Daemon restart recovery
|
||||
|
||||
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
|
||||
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
|
||||
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
|
||||
Disabling recovery consumes pending intent; enabling it later does not revive old work.
|
||||
|
||||
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
|
||||
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
|
||||
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
|
||||
|
||||
Startup chooses candidates before anything else reads or repairs persisted running state, then
|
||||
opens connections and resumes every candidate in the background. A provider that is slow to reopen
|
||||
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
|
||||
closing runtimes.
|
||||
|
||||
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
|
||||
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
|
||||
loop or exactly-once turn guarantee.
|
||||
|
||||
## Runtime residency
|
||||
|
||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
||||
@@ -159,10 +139,8 @@ caller, child, and host into one watch.
|
||||
|
||||
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
||||
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
||||
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
|
||||
recovery is observed only after its resume was sent, so the parent never hears that it was closed
|
||||
in between. Archived or deleted parents suppress delivery;
|
||||
parent-owned creation watches also stop
|
||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
||||
reporting after detachment.
|
||||
|
||||
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
||||
|
||||
@@ -66,9 +66,6 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
|
||||
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
|
||||
these dependency trees with tree-shaking disabled to match unbundled production imports.
|
||||
|
||||
Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that
|
||||
survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md).
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
|
||||
@@ -34,7 +34,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
|
||||
+7
-9
@@ -200,15 +200,13 @@ its own.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
|
||||
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
|
||||
after the start. Whatever moved or resized the surface in between was undone: content that
|
||||
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
|
||||
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
|
||||
leaves no inline style; native keeps the Reanimated `Keyframe`.
|
||||
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
|
||||
so the entering animation runs at the final position rather than ending unseen at the
|
||||
off-screen measuring position.
|
||||
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
|
||||
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
|
||||
identity changes — a pushed page taller than the one it replaced is clipped without that.
|
||||
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
|
||||
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
|
||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||
write moved the open menu back off-screen.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it. A fill page is the one exception.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
|
||||
+19
-35
@@ -99,10 +99,6 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
|
||||
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
|
||||
checks cover that registry. Unlisted directories do not load.
|
||||
|
||||
The `worktree-include` built-in copies gitignored files selected by
|
||||
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
|
||||
before setup and agents, through `worktree.create.after`.
|
||||
|
||||
Desktop packaging ships the entire built-in plugin directory as an external resource,
|
||||
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
|
||||
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
|
||||
@@ -337,10 +333,11 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
The contribution function must return cleanup for resources created by plugin code. Server cleanup
|
||||
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
|
||||
not interrupt host teardown. Client installation retention and disposal follow the public
|
||||
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
|
||||
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it 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, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
||||
errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
@@ -385,15 +382,6 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||
catalog is complete.
|
||||
|
||||
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||
passing the bundle string still costs memory and time once per client lifetime.
|
||||
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||
in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
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. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
@@ -440,22 +428,21 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
|
||||
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
|
||||
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
|
||||
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
|
||||
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
||||
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
||||
|
||||
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
|
||||
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
|
||||
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
|
||||
ownership checks prefer the marker-verified worktree root over the legacy
|
||||
path shape: a plugin can place a worktree several directories below the default worktrees root.
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
|
||||
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
|
||||
re-derived on each open and following the plugin's lifecycle. See
|
||||
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
|
||||
`plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
|
||||
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
|
||||
executable definitions stay in the plugin process.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
@@ -626,10 +613,7 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
|
||||
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
||||
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
||||
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
||||
also run after reconnect; replace your view before applying its subsequent updates. An installation
|
||||
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
|
||||
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
|
||||
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
|
||||
also run after reconnect; replace your view before applying its subsequent updates. The installation
|
||||
owns every observation created through its API and releases them on unload, including setup failure.
|
||||
Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
|
||||
|
||||
@@ -52,20 +52,6 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Terminal ANSI replay
|
||||
|
||||
Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy:
|
||||
use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show
|
||||
only subsequent live output, without the existing screen or history.
|
||||
|
||||
`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source
|
||||
advertising the capability. The SDK keeps the established subscribe request against older
|
||||
ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults
|
||||
to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path.
|
||||
Explicit caller restore modes and history counts pass through unchanged. JSON snapshot
|
||||
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
|
||||
Terminals require `terminal-restore-modes`; older hosts show an update message without attaching.
|
||||
|
||||
## Owned observations
|
||||
|
||||
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
||||
|
||||
+73
-135
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
|
||||
|
||||
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
||||
|
||||
**Preparation** (reversible — agent does this):
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
|
||||
- resolve the release source to one commit and confirm that commit's existing CI is green
|
||||
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
||||
target version and rationale to the user
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
@@ -17,10 +17,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
|
||||
**Go-ahead** (user says "go ahead"):
|
||||
|
||||
- commit the approved release inputs locally on the release branch
|
||||
- run the release, which publishes npm and pushes the release branch and tag
|
||||
- commit the approved release inputs locally
|
||||
- run the release, which publishes npm and pushes the prepared branch and tag
|
||||
- create the release heartbeat immediately and babysit it to completion
|
||||
- [reconcile `main`](#reconcile-main) with what shipped
|
||||
|
||||
Rules that apply to both steps:
|
||||
|
||||
@@ -28,128 +27,56 @@ Rules that apply to both steps:
|
||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
|
||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||
|
||||
## Main stays open
|
||||
## Release source and CI
|
||||
|
||||
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
|
||||
finalized. Each release builds in a short-lived release workspace, and its published tag records
|
||||
the shipped source.
|
||||
The default release source is `origin/main`. Fetch `origin`, then record the
|
||||
resolved commit. The default release checkout is a clean local `main` whose
|
||||
`HEAD` equals `origin/main`.
|
||||
|
||||
## Choosing a path
|
||||
An explicit user instruction can select another ref, such as a hotfix commit or
|
||||
tag. Resolve that ref once and apply every source, diff, and CI check to that
|
||||
commit instead of `origin/main`.
|
||||
|
||||
The user decides when the changes on `main` warrant a release and how much risk it takes:
|
||||
Before making release-preparation commits, confirm the existing CI run for the
|
||||
resolved commit is green. Pending CI is watched to completion. Release
|
||||
preparation then stays local through the changelog, any explicitly requested ACP
|
||||
catalog update, lockfile preparation, and the version commit. After approval,
|
||||
commit the prepared inputs locally and run the release command. Its branch and
|
||||
tag push is the one remote release batch and starts CI for the complete release
|
||||
commit.
|
||||
|
||||
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
|
||||
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
|
||||
review often takes about a day, with no guaranteed timing.
|
||||
2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta`
|
||||
channel. Each beta folds into the series' single changelog entry, publishes npm only on the
|
||||
explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
## Release branch discipline
|
||||
|
||||
A beta follows these defaults, which the user can explicitly override:
|
||||
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
|
||||
release. This applies to both beta and stable releases.
|
||||
|
||||
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
|
||||
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
|
||||
not count for them.
|
||||
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
|
||||
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
|
||||
them.
|
||||
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
|
||||
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
|
||||
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
|
||||
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
|
||||
pace.
|
||||
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
|
||||
- "This goes to next" means create the PR against `next` or retarget an existing
|
||||
PR, and keep that destination through delivery.
|
||||
- Keep `next` current by merging `origin/main` into it as release fixes land.
|
||||
Avoid rebasing this shared branch because agents and open PRs depend on its history.
|
||||
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
|
||||
Pass CI and merge without squashing away the individual PR commits needed for
|
||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||
`next`. Create it fresh when needed again.
|
||||
|
||||
npm dist-tags are independent pointers:
|
||||
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
|
||||
push runs target only `main`, and GitHub permits only squash merges. Enable
|
||||
required-check protection for `next`, CI on its pushes, and merge commits for the
|
||||
integration PR. Handle PR
|
||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||
|
||||
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
|
||||
newest stable.
|
||||
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
|
||||
newer than the version `beta` points to, which covers promotions and direct stable releases, so
|
||||
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
|
||||
`beta` on the active newer beta.
|
||||
### Hotfix from a release tag
|
||||
|
||||
## Release base and source
|
||||
|
||||
The release base is the tag or commit a release workspace starts from. The release source is the
|
||||
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
|
||||
|
||||
| Release | Base | Changes on top |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
|
||||
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
|
||||
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
|
||||
| Promotion | The beta tag being promoted | None by default |
|
||||
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
|
||||
|
||||
An explicit user instruction can select another base.
|
||||
|
||||
1. Fetch `origin` with tags and resolve the base to one commit.
|
||||
2. Create the release workspace through Paseo MCP on a new branch from the base, named
|
||||
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
|
||||
this workspace: `release:push` pushes `HEAD` to the current branch.
|
||||
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
|
||||
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
|
||||
Resolve conflicts within the approved changes and report how. A resolution that needs new
|
||||
behavior is a scope addition: surface it for selection.
|
||||
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
|
||||
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
|
||||
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
|
||||
because CI push runs target only `main`.
|
||||
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
|
||||
version commit local until the go-ahead. The release command pushes the release branch and tag
|
||||
as one batch, and the tag push starts the release workflows. Then run
|
||||
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
|
||||
6. [Reconcile `main`](#reconcile-main).
|
||||
|
||||
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
|
||||
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
|
||||
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
|
||||
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
|
||||
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
|
||||
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
|
||||
for the unchanged content.
|
||||
|
||||
## Release inventory
|
||||
|
||||
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
|
||||
not, then get the batch approved. There is no release label or queue.
|
||||
|
||||
1. Find the commits on `origin/main` whose patches are not in the base tag:
|
||||
|
||||
```bash
|
||||
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
|
||||
```
|
||||
|
||||
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
|
||||
content over PR state or branch names.
|
||||
|
||||
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
|
||||
prerequisites an included PR depends on and anything that adds scope beyond what the beta
|
||||
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
|
||||
preserves its behavior, directly or through a superseding change. List the missing ones for
|
||||
explicit selection; adding one to a promotion needs a new beta.
|
||||
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
|
||||
authorization they already gave covers it.
|
||||
|
||||
For a promotion, every item defaults to defer.
|
||||
|
||||
## Reconcile main
|
||||
|
||||
After the release push, open a PR into `main` that brings in what shipped and keeps newer
|
||||
development on `main`:
|
||||
|
||||
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
|
||||
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
|
||||
below any newer entries.
|
||||
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
|
||||
carries a newer version, plus the F-Droid changelog files a stable release generates.
|
||||
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
|
||||
`main` holds for changes that have not shipped stay out of it.
|
||||
|
||||
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
|
||||
maintains no release track besides `main`.
|
||||
If `main` contains changes you do not want to release, branch from the affected
|
||||
release tag and cherry-pick only the required fixes. Run CI on that branch, then
|
||||
use the normal release flow with it as the explicit source, choosing a new patch
|
||||
or beta version. Ensure the fixes and changelog also reach `main` and any active
|
||||
`next`, preserving newer development and version changes there. This is a
|
||||
short-lived hotfix branch, not another maintained release track.
|
||||
|
||||
## ACP catalog updates
|
||||
|
||||
@@ -164,6 +91,21 @@ ACP catalog work enters a release through an explicit user request:
|
||||
The release authorization covers the requested ACP commit. It ships in the same
|
||||
release push as the changelog and version commit.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
|
||||
- A beta release moves only `beta`; `latest` remains on the newest stable.
|
||||
- A stable release moves both `latest` and `beta` to that stable version. This
|
||||
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
|
||||
a beta is promoted or superseded by a direct stable release.
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full diff from the previous
|
||||
@@ -191,7 +133,7 @@ as described in **Fixing a failed release build**.
|
||||
|
||||
Before running any stable release command:
|
||||
|
||||
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
|
||||
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
@@ -203,9 +145,9 @@ npm run release:minor
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||
|
||||
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
|
||||
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
|
||||
do not republish the packages:
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
republish the packages:
|
||||
|
||||
```bash
|
||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||
@@ -214,8 +156,8 @@ for package in highlight relay protocol client plugin server cli; do
|
||||
done
|
||||
```
|
||||
|
||||
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
|
||||
active newer beta, before considering the stable release complete.
|
||||
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
||||
stable release complete.
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
@@ -233,7 +175,7 @@ npm run version:all:patch
|
||||
npm run version:all:minor
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
|
||||
# Then move npm's beta dist-tag to this stable version using the command above.
|
||||
```
|
||||
|
||||
## Beta flow
|
||||
@@ -380,8 +322,7 @@ There is no mobile-release workflow under `.github/workflows`. The EAS GitHub ap
|
||||
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
|
||||
|
||||
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
|
||||
- When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
|
||||
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
|
||||
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
|
||||
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
|
||||
|
||||
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
|
||||
@@ -547,7 +488,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the code you want built, with the fix included.
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||
@@ -690,7 +631,7 @@ Rules:
|
||||
|
||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||
|
||||
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
@@ -722,11 +663,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
|
||||
### Beta release
|
||||
|
||||
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
@@ -737,24 +678,22 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
||||
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
||||
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
||||
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
|
||||
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
@@ -768,5 +707,4 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
@@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu
|
||||
|
||||
```
|
||||
pty (node-pty, forked worker process)
|
||||
→ headless xterm parse (worker, ANSI replay fidelity)
|
||||
→ TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
|
||||
→ framed local socket → daemon main process
|
||||
→ headless xterm parse (worker, snapshot fidelity)
|
||||
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
|
||||
→ process.send IPC → daemon main process
|
||||
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
|
||||
→ binary ws frame (2-byte header + raw bytes)
|
||||
→ client decode (daemon-client.ts) → stream router → emulator runtime
|
||||
@@ -19,13 +19,10 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
|
||||
## Invariants (the easy-to-break ones)
|
||||
|
||||
- **Replay is incremental across the daemon and SDK.** The worker retains 1,000 history rows. Capable clients request their configured history; the worker bounds it by retained history. Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
|
||||
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
|
||||
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal.
|
||||
- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay.
|
||||
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
|
||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Terminal worker
|
||||
|
||||
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
|
||||
|
||||
## Process model
|
||||
|
||||
```
|
||||
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
|
||||
exits (re-parented to init)
|
||||
```
|
||||
|
||||
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
|
||||
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
|
||||
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
## Record, socket and secret
|
||||
|
||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
|
||||
|
||||
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
|
||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
|
||||
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
|
||||
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
|
||||
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
|
||||
|
||||
## Protocol versions
|
||||
|
||||
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
|
||||
|
||||
| Worker reports | Daemon does |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| its own version | adopts it |
|
||||
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
|
||||
| two or more older | shuts it down; its terminals close (logged) |
|
||||
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
|
||||
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
|
||||
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
|
||||
|
||||
## Display replay
|
||||
|
||||
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
|
||||
|
||||
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
|
||||
|
||||
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
|
||||
|
||||
The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
|
||||
|
||||
## Running from its own copy
|
||||
|
||||
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
|
||||
|
||||
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
|
||||
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
|
||||
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
|
||||
- Dev (`.ts` source) launches from source and copies nothing.
|
||||
|
||||
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
|
||||
|
||||
## Desktop
|
||||
|
||||
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
|
||||
|
||||
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
|
||||
|
||||
## Known limits
|
||||
|
||||
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
|
||||
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
|
||||
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
|
||||
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
|
||||
@@ -10,11 +10,6 @@ then replaces the previous tool state or merges into the current text item. Inte
|
||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||
and rebuilds the projection when an agent resumes.
|
||||
|
||||
Pagination bounds each read, not total resident history. Active and idle agents retain their
|
||||
projected transcript; archive releases it and later history reads rebuild it from the provider.
|
||||
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
|
||||
must be released after their deltas have been consumed.
|
||||
|
||||
The invariants are:
|
||||
|
||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||
|
||||
@@ -358,14 +358,6 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
|
||||
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||
Electron running the old code.
|
||||
|
||||
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
|
||||
owned by Unistyles' change listeners. Chromium defers their events while a window
|
||||
is hidden; reading `matches` on those same lists can consume the pending change.
|
||||
CSS variables then follow the OS theme while `withUnistyles` props retain the old
|
||||
colors. Keep this patch until upstream reads the scheme through separate lists.
|
||||
The desktop browser E2E checks both transition directions in an isolated Electron
|
||||
process, because Playwright's focus emulation prevents real hidden-page behavior.
|
||||
|
||||
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||
`patches/` is inert unless that script knows which installed package activates
|
||||
it.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
|
||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Upload Google Play screenshots
|
||||
|
||||
# Standalone re-trigger for the release workflow's Google Play screenshot step.
|
||||
# Replaces the en-US phone and tablet screenshots with the committed renders,
|
||||
# without rebuilding or resubmitting the app.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
android_version_code:
|
||||
type: string
|
||||
required: false
|
||||
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
|
||||
|
||||
jobs:
|
||||
upload_android_screenshots:
|
||||
name: Upload Google Play screenshots
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Upload screenshots
|
||||
run: |
|
||||
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
|
||||
bundle exec fastlane android upload_screenshots
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
version: nativeReleaseVersion.appVersion,
|
||||
orientation: "default",
|
||||
orientation: "portrait",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "paseo",
|
||||
userInterfaceStyle: "automatic",
|
||||
|
||||
@@ -59,38 +59,3 @@ describe("Android app config", () => {
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("follows system rotation on Android", async () => {
|
||||
const manifest = await resolveAndroidManifest();
|
||||
const activity = manifest.application[0].activity.find(
|
||||
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
|
||||
);
|
||||
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
|
||||
}, 30_000);
|
||||
|
||||
it("allows all orientations on iPhone and iPad", async () => {
|
||||
const { mkdtemp, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
|
||||
try {
|
||||
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
|
||||
await compileModsAsync(exp, {
|
||||
projectRoot: root,
|
||||
introspect: true,
|
||||
platforms: ["ios"],
|
||||
assertMissingModProviders: false,
|
||||
});
|
||||
const plist = exp._internal.modResults.ios.infoPlist;
|
||||
const orientations = [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationPortraitUpsideDown",
|
||||
"UIInterfaceOrientationLandscapeLeft",
|
||||
"UIInterfaceOrientationLandscapeRight",
|
||||
];
|
||||
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
|
||||
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
openDaemonOverview,
|
||||
restartDaemonInSettings,
|
||||
expectDaemonRestartComplete,
|
||||
expectDaemonRestartFailed,
|
||||
plantUnclosableTerminalProgram,
|
||||
readWorkerPid,
|
||||
openLongLivedTerminal,
|
||||
listTerminalIds,
|
||||
suspendDaemonProcess,
|
||||
resumeDaemonProcess,
|
||||
daemonProcessCanWrite,
|
||||
readTerminalProcessIds,
|
||||
} from "../support/helpers/daemon-lifecycle";
|
||||
|
||||
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
||||
@@ -38,57 +29,6 @@ test("settings restart waits for a replacement worker while retaining the superv
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart keeps a terminal", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
try {
|
||||
const terminalId = await openLongLivedTerminal(daemon);
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
expect(await listTerminalIds(daemon)).toContain(terminalId);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart and close terminals closes the terminal", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
try {
|
||||
await openLongLivedTerminal(daemon);
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page, { closeTerminals: true });
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
expect(await listTerminalIds(daemon)).toEqual([]);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart and close terminals shows the processes it could not close", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
|
||||
try {
|
||||
program = await plantUnclosableTerminalProgram(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page, { closeTerminals: true });
|
||||
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("restart-cleanup-failed.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
program?.release();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -124,51 +64,3 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
|
||||
// A stopped worker deterministically outlives the old helper's five-second
|
||||
// supervisor-only kill and logs into its deleted home when resumed.
|
||||
cleanupTest(
|
||||
"isolated cleanup prevents a stopped worker from recreating its deleted home",
|
||||
async () => {
|
||||
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
const workerPid = await readWorkerPid(daemon);
|
||||
try {
|
||||
suspendDaemonProcess(workerPid);
|
||||
await daemon.close();
|
||||
resumeDaemonProcess(workerPid);
|
||||
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
|
||||
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||
} finally {
|
||||
resumeDaemonProcess(workerPid);
|
||||
await daemon.close();
|
||||
await rm(daemon.paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cleanupTest(
|
||||
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
|
||||
async () => {
|
||||
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
const supervisorPid = daemon.getPid()!;
|
||||
const workerPid = await readWorkerPid(daemon);
|
||||
try {
|
||||
await openLongLivedTerminal(daemon);
|
||||
const terminalProcesses = await readTerminalProcessIds(daemon);
|
||||
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
|
||||
suspendDaemonProcess(supervisorPid);
|
||||
await daemon.close();
|
||||
const writerStates = await Promise.all(
|
||||
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
|
||||
);
|
||||
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
|
||||
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||
} finally {
|
||||
resumeDaemonProcess(supervisorPid);
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import {
|
||||
withRetainedPlugin,
|
||||
editRetainedDraft,
|
||||
expectRetainedDraft,
|
||||
expectStoredPluginTitle,
|
||||
hideRetainedPreferences,
|
||||
showRetainedPreferences,
|
||||
expectChangedPlugin,
|
||||
expectRemovedPlugin,
|
||||
} from "../support/helpers/plugin-reconnect";
|
||||
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -198,51 +188,3 @@ test("plugin content and host labels follow app language without losing an open
|
||||
test.setTimeout(180_000);
|
||||
await verifyPluginTranslations(page, info);
|
||||
});
|
||||
|
||||
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withRetainedPlugin(page, async (probe) => {
|
||||
await probe.open();
|
||||
await editRetainedDraft(page, "Connected draft", 0);
|
||||
|
||||
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
|
||||
await probe.disconnect();
|
||||
await expectRetainedDraft(page, "Connected draft", 1);
|
||||
await editRetainedDraft(page, "Offline draft", 1);
|
||||
await probe.changeStoredTitle("Saved by another client");
|
||||
await expectStoredPluginTitle(page, "Agent monitor");
|
||||
await probe.reconnect();
|
||||
await expectStoredPluginTitle(page, "Saved by another client");
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
});
|
||||
|
||||
await test.step("inactive settings refresh without replacing the installation", async () => {
|
||||
await hideRetainedPreferences(page);
|
||||
await probe.disconnect();
|
||||
await probe.changeStoredTitle("Saved while preferences hidden");
|
||||
await probe.reconnect();
|
||||
await showRetainedPreferences(page);
|
||||
await expectStoredPluginTitle(page, "Saved while preferences hidden");
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
});
|
||||
|
||||
await test.step("changed code replaces the retained installation after reconnect", async () => {
|
||||
await probe.disconnect();
|
||||
await probe.changeCode();
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
await probe.reconnect();
|
||||
await expectChangedPlugin(page);
|
||||
await editRetainedDraft(page, "New code draft", 0);
|
||||
});
|
||||
|
||||
await test.step("removal is reconciled after reconnect", async () => {
|
||||
await probe.disconnect();
|
||||
await probe.remove();
|
||||
await expectRetainedDraft(page, "New code draft", 1);
|
||||
await probe.reconnect();
|
||||
await expectRemovedPlugin(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
@@ -7,8 +6,6 @@ import {
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
expectBothConsecutiveTools,
|
||||
trackPluginChatRecovery,
|
||||
reconnectPluginChat,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
@@ -16,24 +13,13 @@ for (const width of [1100, 390]) {
|
||||
page,
|
||||
}, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||
await requestPluginTimeline(agent);
|
||||
await interactWithStreamingCard(page);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectWholeCompletedCard(page);
|
||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||
const recovery = trackPluginChatRecovery(page);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await recovery.expectCatalogWithoutRetransmission();
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expectWholeCompletedCard(page);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
recovery.reset();
|
||||
await reconnectPluginChat(page, gate);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
await expectWholeCompletedCard(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,26 +204,19 @@ test.describe("Projects settings", () => {
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("projects-settings-empty-");
|
||||
const client = await connectSeedClient();
|
||||
let projectViewKey: string | null = null;
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
projectViewKey = await addProjectFromSidebar(page, repo.path);
|
||||
await openProjectSettingsFromSidebar(page, projectViewKey);
|
||||
projectId = await addProjectFromSidebar(page, repo.path);
|
||||
await openProjectSettingsFromSidebar(page, projectId);
|
||||
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
||||
} finally {
|
||||
if (projectViewKey) {
|
||||
const project = (await client.listProjects()).projects.find(
|
||||
(entry) => entry.projectKey === projectViewKey,
|
||||
);
|
||||
expect(project).toBeDefined();
|
||||
await client.removeProject(project!.projectId);
|
||||
expect(
|
||||
(await client.listProjects()).projects.map((entry) => entry.projectKey),
|
||||
).not.toContain(projectViewKey);
|
||||
if (projectId) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
|
||||
@@ -30,10 +30,6 @@ import {
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
expectIdleAgentRelease,
|
||||
editRestartRecoveryPrompt,
|
||||
expectRestartRecoveryEnabled,
|
||||
expectRestartRecoveryPrompt,
|
||||
setRestartRecoveryEnabled,
|
||||
setIdleAgentRelease,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
@@ -256,20 +252,3 @@ test.describe("Settings host page", () => {
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
});
|
||||
|
||||
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectRestartRecoveryEnabled(page, true);
|
||||
await expectRestartRecoveryPrompt(page, "Resume working.");
|
||||
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
await setRestartRecoveryEnabled(page, false);
|
||||
await page.reload();
|
||||
await expectRestartRecoveryEnabled(page, false);
|
||||
await setRestartRecoveryEnabled(page, true);
|
||||
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
await editRestartRecoveryPrompt(page, "Resume working.");
|
||||
});
|
||||
|
||||
@@ -53,18 +53,11 @@ async function quickDragFirstRowAfterSecond(
|
||||
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 finishSidebarDrag(page);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
async function finishSidebarDrag(page: Page): Promise<void> {
|
||||
await page.mouse.up();
|
||||
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
|
||||
// Row order updates before that blocker is removed; wait before the next user action.
|
||||
await page.waitForTimeout(60);
|
||||
}
|
||||
|
||||
async function dragWithoutReordering(rows: Locator): Promise<void> {
|
||||
const before = await rowTestIds(rows);
|
||||
const source = await visibleBoundingBox(rows.nth(0));
|
||||
@@ -73,7 +66,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
||||
await finishSidebarDrag(page);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const DRAG_DISTANCE = -40;
|
||||
const TOLERANCE_PX = 2;
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function dividerX(divider: Locator): Promise<number> {
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
return box.x;
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
const input = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await input.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
|
||||
});
|
||||
}
|
||||
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
} finally {
|
||||
await input.detach();
|
||||
}
|
||||
}
|
||||
|
||||
async function splitIntoFourPanes(page: Page): Promise<void> {
|
||||
for (const expectedDividers of [1, 2, 3]) {
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(splitDividers(page)).toHaveCount(expectedDividers);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectDividersTrackPointer(
|
||||
page: Page,
|
||||
distance = DRAG_DISTANCE,
|
||||
drag = dragDivider,
|
||||
): Promise<void> {
|
||||
for (const index of [0, 1, 2]) {
|
||||
const divider = splitDividers(page).nth(index);
|
||||
const before = await dividerX(divider);
|
||||
await drag(divider, distance);
|
||||
await expect
|
||||
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
|
||||
message: `divider ${index + 1} should stay under the pointer`,
|
||||
})
|
||||
.toBeLessThanOrEqual(TOLERANCE_PX);
|
||||
}
|
||||
}
|
||||
|
||||
for (const hasTouch of [false, true]) {
|
||||
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
|
||||
test.use({ hasTouch });
|
||||
|
||||
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await splitIntoFourPanes(page);
|
||||
await expectDividersTrackPointer(
|
||||
page,
|
||||
DRAG_DISTANCE,
|
||||
hasTouch ? dragDividerWithTouch : dragDivider,
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Reply briefly.", "Create");
|
||||
await creation.expectAssistantReply();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await splitIntoFourPanes(page);
|
||||
await expectDividersTrackPointer(page);
|
||||
await page
|
||||
.getByTestId("workspace-explorer-toggle")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
|
||||
await page
|
||||
.getByTestId("workspace-explorer-toggle")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
|
||||
await page.reload();
|
||||
await expect(splitDividers(page)).toHaveCount(3);
|
||||
await expectDividersTrackPointer(page);
|
||||
} finally {
|
||||
await creation.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
|
||||
interface AttachOverlayProbeWindow extends Window {
|
||||
@@ -60,34 +59,4 @@ test.describe("retained terminal tab streams", () => {
|
||||
|
||||
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
|
||||
});
|
||||
|
||||
for (const { count, retainedMarker } of [
|
||||
{ count: 400, retainedMarker: "history-0001界🙂" },
|
||||
{ count: 3000, retainedMarker: "history-2500界🙂" },
|
||||
]) {
|
||||
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
|
||||
});
|
||||
const terminal = await harness.createTerminal({
|
||||
name: `history-${count}`,
|
||||
command: "/bin/sh",
|
||||
args: [
|
||||
"-lc",
|
||||
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
|
||||
],
|
||||
});
|
||||
const last = `history-${String(count).padStart(4, "0")}界🙂`;
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
await page.reload();
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
|
||||
await openCompactUsageWithClock(page, now);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
|
||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||
await advanceUsageClock(page, 120_000);
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||
/^resets in (28|29)m\s·\s/,
|
||||
/^resets (28|29)m$/,
|
||||
);
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { writeFile, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -19,27 +15,14 @@ export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon)
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
}
|
||||
|
||||
export async function restartDaemonInSettings(
|
||||
page: Page,
|
||||
options: { closeTerminals?: boolean } = {},
|
||||
) {
|
||||
const button = page.getByRole("button", {
|
||||
name: options.closeTerminals ? "Restart and close terminals" : "Restart",
|
||||
exact: true,
|
||||
});
|
||||
export async function restartDaemonInSettings(page: Page) {
|
||||
const button = page.getByRole("button", { name: "Restart", exact: true });
|
||||
await expect(button).toBeEnabled();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await button.click();
|
||||
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectDaemonRestartFailed(page: Page, text: string) {
|
||||
const error = page.getByTestId("host-page-restart-error");
|
||||
await expect(error).toBeVisible({ timeout: 30_000 });
|
||||
await expect(error).toContainText(text);
|
||||
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function expectDaemonRestartComplete(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
@@ -57,139 +40,3 @@ export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number>
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a terminal on the isolated daemon that runs a long-lived program. */
|
||||
export async function openLongLivedTerminal(daemon: IsolatedHostDaemon): Promise<string> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: daemon.port,
|
||||
clientIdPrefix: "lifecycle-terminal",
|
||||
});
|
||||
try {
|
||||
const workspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: daemon.paseoHome },
|
||||
title: "lifecycle",
|
||||
});
|
||||
if (!workspace.workspace) throw new Error(workspace.error ?? "Workspace creation failed");
|
||||
const created = await client.createTerminal(daemon.paseoHome, "lifecycle", undefined, {
|
||||
workspaceId: workspace.workspace.id,
|
||||
command: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1e9)"],
|
||||
});
|
||||
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
|
||||
return created.terminal.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<string[]> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: daemon.port,
|
||||
clientIdPrefix: "lifecycle-terminals",
|
||||
});
|
||||
try {
|
||||
return (await client.listTerminals()).terminals.map((terminal) => terminal.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
|
||||
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
|
||||
env: { ...process.env, LC_ALL: "C" },
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export interface UnclosableTerminalProgram {
|
||||
pid: number;
|
||||
/** Ends the program's parent, which lets the system reap the program. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves in the daemon's home the record a dead terminal worker leaves, naming one terminal
|
||||
* program that SIGKILL cannot remove: a zombie whose parent (an exec'd `sleep`) never waits for
|
||||
* it. A kill cleanup can only report it as still running. POSIX only.
|
||||
*/
|
||||
export async function plantUnclosableTerminalProgram(
|
||||
daemon: IsolatedHostDaemon,
|
||||
): Promise<UnclosableTerminalProgram> {
|
||||
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
parent.unref();
|
||||
const release = () => {
|
||||
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
|
||||
};
|
||||
try {
|
||||
const pid = await new Promise<number>((resolve) => {
|
||||
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
|
||||
});
|
||||
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
|
||||
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
|
||||
await new Promise((resolve) => gone.once("exit", resolve));
|
||||
const record = {
|
||||
recordId: "planted-record",
|
||||
pid: gone.pid,
|
||||
bootId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
|
||||
protocolVersion: 1,
|
||||
paseoVersion: "0.0.0-test",
|
||||
socketPath: path.join(daemon.paseoHome, "planted-worker.sock"),
|
||||
secret: "planted-secret",
|
||||
terminals: [{ id: "planted-terminal", pid, startedAt: await psField(pid, "lstart") }],
|
||||
};
|
||||
await writeFile(
|
||||
path.join(daemon.paseoHome, "terminal-worker", `${gone.pid}.json`),
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { pid, release };
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
|
||||
export function suspendDaemonProcess(pid: number): void {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
}
|
||||
|
||||
export function resumeDaemonProcess(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, "SIGCONT");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
|
||||
try {
|
||||
const state = await psField(pid, "stat");
|
||||
return state.length > 0 && !state.startsWith("Z");
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown }).code === 1) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
|
||||
const directory = path.join(daemon.paseoHome, "terminal-worker");
|
||||
const records = await Promise.all(
|
||||
(await readdir(directory))
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => {
|
||||
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
|
||||
pid: number;
|
||||
terminals: Array<{ pid: number }>;
|
||||
};
|
||||
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
|
||||
}),
|
||||
);
|
||||
return records.flat();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
spawn,
|
||||
execFile,
|
||||
execFileSync,
|
||||
type ChildProcess,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -13,8 +6,6 @@ import path from "node:path";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
@@ -161,24 +152,6 @@ export async function startIsolatedHostDaemon(
|
||||
const serverDir = publishedPackageRoot
|
||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||
: path.resolve(__dirname, "../../../../server");
|
||||
let child: ChildProcess | null = null;
|
||||
const stopDaemon = async (): Promise<void> => {
|
||||
// The CLI owns graceful shutdown, forced cleanup and detached terminal
|
||||
// reaping. Keep its ESM graph out of Playwright's transforming loader.
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, "../../../../cli/dist/index.js"),
|
||||
"daemon",
|
||||
"stop",
|
||||
"--home",
|
||||
paseoHome,
|
||||
"--force",
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await killProcessTree(child);
|
||||
};
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: serverDir,
|
||||
@@ -196,21 +169,21 @@ export async function startIsolatedHostDaemon(
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
};
|
||||
const spawned = publishedPackageRoot
|
||||
const child = publishedPackageRoot
|
||||
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
|
||||
|
||||
child = spawned;
|
||||
let stderr = "";
|
||||
spawned.stderr?.on("data", (chunk: Buffer) => {
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, spawned);
|
||||
return spawned;
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
} catch (error) {
|
||||
await killProcessTree(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -218,10 +191,10 @@ export async function startIsolatedHostDaemon(
|
||||
}
|
||||
};
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
await spawnDaemon();
|
||||
child = await spawnDaemon();
|
||||
} catch (error) {
|
||||
await stopDaemon();
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -236,22 +209,22 @@ export async function startIsolatedHostDaemon(
|
||||
serverId,
|
||||
port,
|
||||
paseoHome,
|
||||
getPid: () => child?.pid,
|
||||
getPid: () => child.pid,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopDaemon();
|
||||
await spawnDaemon();
|
||||
await killProcessTree(child);
|
||||
child = await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
await stopDaemon();
|
||||
closed = true;
|
||||
await killProcessTree(child);
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
if (publishedPackageRoot) {
|
||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||
}
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { z } from "zod";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
const PLUGIN_ID = "retained-plugin-e2e";
|
||||
|
||||
export function retainedPluginSource(version: string) {
|
||||
return `import { useState } from "react";
|
||||
import { Text, View, TextInput, Pressable } from "react-native";
|
||||
import { useSettings } from "@getpaseo/plugin/client";
|
||||
import { preferences } from "./shared/preferences";
|
||||
function Preferences({ color }) {
|
||||
const settings = useSettings(preferences);
|
||||
return <Text style={{ color }}>{settings.status === "ready" ? "Stored title: " + settings.values.title : "Preferences " + settings.status}</Text>;
|
||||
}
|
||||
function Surface({ theme }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [clicks, setClicks] = useState(0);
|
||||
const [showPreferences, setShowPreferences] = useState(true);
|
||||
const color = theme.colors.foreground;
|
||||
return <View>
|
||||
<Text style={{ color }}>Plugin version ${version}</Text>
|
||||
<TextInput accessibilityLabel="Plugin draft" value={draft} onChangeText={setDraft} style={{ color }} />
|
||||
<Pressable accessibilityRole="button" onPress={() => setClicks(value => value + 1)}><Text style={{ color }}>{"Local clicks " + clicks}</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => setShowPreferences(value => !value)}><Text style={{ color }}>{showPreferences ? "Hide preferences" : "Show preferences"}</Text></Pressable>
|
||||
{showPreferences ? <Preferences color={color} /> : null}
|
||||
</View>;
|
||||
}
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: "Reconnect probe", icon: "PanelsTopLeft", surface: "main" });
|
||||
return () => {};
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function withRetainedPlugin(
|
||||
page: Page,
|
||||
run: (probe: Awaited<ReturnType<typeof createProbe>>) => Promise<void>,
|
||||
) {
|
||||
const probe = await createProbe(page);
|
||||
try {
|
||||
await run(probe);
|
||||
} finally {
|
||||
await probe.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function createProbe(page: Page) {
|
||||
const example = await copyPluginExample("settings");
|
||||
const peer = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "plugin-reconnect" });
|
||||
const previous = await peer.getDaemonConfig();
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const clientEntry = path.join(example.directory, "index.client.tsx");
|
||||
let catalogCount = 0;
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(example.directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(clientEntry, retainedPluginSource("one"));
|
||||
await peer.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await peer.installDirectoryPlugin(example.directory);
|
||||
} catch (error) {
|
||||
await peer.close();
|
||||
await example.cleanup();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
async open() {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Reconnect probe", exact: true }).click();
|
||||
await expect(page.getByText("Stored title: Agent monitor", { exact: true })).toBeVisible();
|
||||
},
|
||||
async disconnect() {
|
||||
catalogCount = gate.getClientRequestCount("plugin.catalog.get.request");
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
},
|
||||
async reconnect() {
|
||||
gate.restore();
|
||||
await gate.waitForServerMessage("plugin.catalog.get.response", catalogCount + 1);
|
||||
},
|
||||
async changeStoredTitle(title: string) {
|
||||
const rpc = settingsRpc("display");
|
||||
const current = rpc.read.output.parse(
|
||||
await peer.invokePluginRpc(PLUGIN_ID, rpc.read.name, {}),
|
||||
);
|
||||
if (current.status !== "ready") throw new Error("Expected ready settings");
|
||||
const values = z
|
||||
.object({ groupBy: z.string(), showMetadata: z.boolean(), title: z.string() })
|
||||
.parse(current.values);
|
||||
const result = await peer.invokePluginRpc(PLUGIN_ID, rpc.write.name, {
|
||||
revision: current.revision,
|
||||
values: { ...values, title },
|
||||
});
|
||||
expect(result).toMatchObject({ status: "saved", values: { title } });
|
||||
},
|
||||
async changeCode() {
|
||||
await writeFile(clientEntry, retainedPluginSource("two"));
|
||||
await peer.reloadPlugin(PLUGIN_ID);
|
||||
},
|
||||
async remove() {
|
||||
await peer.removePlugin(PLUGIN_ID);
|
||||
},
|
||||
async close() {
|
||||
gate.restore();
|
||||
await peer.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await peer.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await peer.close();
|
||||
await example.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function editRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||
await page.getByRole("textbox", { name: "Plugin draft", exact: true }).fill(draft);
|
||||
await page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }).click();
|
||||
await expectRetainedDraft(page, draft, clicks + 1);
|
||||
}
|
||||
|
||||
export async function expectRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||
await expect(page.getByRole("textbox", { name: "Plugin draft", exact: true })).toHaveValue(draft);
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectStoredPluginTitle(page: Page, title: string) {
|
||||
await expect(page.getByText(`Stored title: ${title}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function hideRetainedPreferences(page: Page) {
|
||||
await page.getByRole("button", { name: "Hide preferences", exact: true }).click();
|
||||
await expect(page.getByText(/^Stored title:/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function showRetainedPreferences(page: Page) {
|
||||
await page.getByRole("button", { name: "Show preferences", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectChangedPlugin(page: Page) {
|
||||
await expect(page.getByText("Plugin version two", { exact: true })).toBeVisible();
|
||||
await expectRetainedDraft(page, "", 0);
|
||||
}
|
||||
|
||||
export async function expectRemovedPlugin(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin surface is unavailable.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Reconnect probe", exact: true })).toHaveCount(0);
|
||||
}
|
||||
@@ -5,64 +5,8 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "timeline-transform-e2e";
|
||||
|
||||
/** Observe the app's real socket, including fresh sockets after a reload. */
|
||||
export function trackPluginChatRecovery(page: Page) {
|
||||
const messages: {
|
||||
type: string;
|
||||
payload?: { plugins?: { id: string; clientBundle?: string }[] };
|
||||
}[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
const collect = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.type === "session") messages.push(frame.message);
|
||||
};
|
||||
socket.on("framesent", collect);
|
||||
socket.on("framereceived", collect);
|
||||
});
|
||||
return {
|
||||
reset() {
|
||||
messages.length = 0;
|
||||
},
|
||||
async expectCatalogWithoutRetransmission() {
|
||||
await expect
|
||||
.poll(() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "plugin.catalog.get.response" &&
|
||||
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const entry = messages
|
||||
.find((message) => message.type === "plugin.catalog.get.response")
|
||||
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry).not.toHaveProperty("clientBundle");
|
||||
},
|
||||
async expectRecoveredWithoutRetransmission() {
|
||||
await this.expectCatalogWithoutRetransmission();
|
||||
await expect
|
||||
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||
.toBe(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconnectPluginChat(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
gate.restore();
|
||||
}
|
||||
export const ASSISTANT_TEXT =
|
||||
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
||||
|
||||
|
||||
@@ -560,41 +560,3 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
function restartRecoverySwitch(page: Page) {
|
||||
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
|
||||
}
|
||||
|
||||
function restartRecoveryEditButton(page: Page) {
|
||||
return page.getByRole("button", { name: "Edit prompt", exact: true });
|
||||
}
|
||||
|
||||
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
|
||||
const toggle = restartRecoverySwitch(page);
|
||||
await toggle.click();
|
||||
await expectRestartRecoveryEnabled(page, enabled);
|
||||
}
|
||||
|
||||
// The prompt is only editable while restart recovery is on.
|
||||
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
|
||||
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
|
||||
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
|
||||
}
|
||||
|
||||
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||
await restartRecoveryEditButton(page).click();
|
||||
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
|
||||
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
|
||||
await sheet.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||
await restartRecoveryEditButton(page).click();
|
||||
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
|
||||
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
|
||||
prompt,
|
||||
);
|
||||
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -26,22 +26,6 @@ def stage_app_store_screenshots
|
||||
root
|
||||
end
|
||||
|
||||
# Google Play only has en-US screenshots, and some F-Droid locales in
|
||||
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
|
||||
# every locale folder in one edit, so give it only the en-US images.
|
||||
def stage_google_play_screenshots
|
||||
root = Dir.mktmpdir("google-play-screenshots")
|
||||
images = File.join(ANDROID_METADATA, "en-US/images")
|
||||
%w[phoneScreenshots tenInchScreenshots].each do |kind|
|
||||
target = File.join(root, "en-US/images", kind)
|
||||
FileUtils.mkdir_p(target)
|
||||
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
|
||||
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
|
||||
end
|
||||
end
|
||||
root
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Submit the latest TestFlight build to App Store Review"
|
||||
lane :submit_review do
|
||||
@@ -148,7 +132,7 @@ platform :android do
|
||||
supply(
|
||||
package_name: APP_IDENTIFIER,
|
||||
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
|
||||
metadata_path: stage_google_play_screenshots,
|
||||
metadata_path: ANDROID_METADATA,
|
||||
version_code: version_code.empty? ? nil : version_code.to_i,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import equal from "fast-deep-equal";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { router } from "expo-router";
|
||||
@@ -23,7 +22,14 @@ import {
|
||||
type ComponentType,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Modal, View } from "react-native";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet as RNStyleSheet,
|
||||
ScrollView,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ResizeHandle({
|
||||
const { theme } = useUnistyles();
|
||||
const finePointer = useHasFinePointer();
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const touchStateRef = useRef<PointerState | null>(null);
|
||||
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
@@ -151,30 +151,23 @@ export function ResizeHandle({
|
||||
const touchGesture = useMemo(() => {
|
||||
const gesture = Gesture.Pan()
|
||||
.runOnJS(true)
|
||||
.onBegin((event) => {
|
||||
setDragging(true);
|
||||
touchStateRef.current = {
|
||||
containerSize,
|
||||
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
|
||||
drag: startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||
}),
|
||||
};
|
||||
.onBegin(() => setDragging(true))
|
||||
.onStart(() => {
|
||||
touchDragRef.current = startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const state = touchStateRef.current;
|
||||
if (!state || state.containerSize <= 0) return;
|
||||
// Gesture Handler resets web translation when the pan activates. Measure from
|
||||
// the initial press so the activation distance is included on every platform.
|
||||
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
|
||||
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
|
||||
if (containerSize <= 0) return;
|
||||
const translation = direction === "horizontal" ? event.translationX : event.translationY;
|
||||
touchDragRef.current?.move(translation / containerSize);
|
||||
})
|
||||
.onEnd(() => touchStateRef.current?.drag.finish())
|
||||
.onEnd(() => touchDragRef.current?.finish())
|
||||
.onFinalize(() => {
|
||||
touchStateRef.current = null;
|
||||
touchDragRef.current = null;
|
||||
setDragging(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
import {
|
||||
collectAllPanes,
|
||||
createDefaultLayout,
|
||||
selectExplorerSidebarPaneId,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -184,8 +183,8 @@ export function SplitContainer({
|
||||
} | null>(null);
|
||||
const maximizedPaneId =
|
||||
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
||||
selectExplorerSidebarPaneId(state, workspaceKey),
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
|
||||
);
|
||||
|
||||
const mainRoot = useMemo(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { Ref } from "react";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type {
|
||||
@@ -18,7 +19,8 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
writeOutput: (data: TerminalOutputData) => void;
|
||||
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
|
||||
restoreOutput: (data: TerminalOutputData) => void;
|
||||
renderSnapshot: (state: TerminalState | null) => void;
|
||||
paste: (text: string) => void;
|
||||
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
|
||||
clear: () => void;
|
||||
@@ -42,6 +44,7 @@ export interface TerminalEmulatorProps {
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
initialSnapshot?: TerminalState | null;
|
||||
onFindRequest?: () => void;
|
||||
onFindResult?: (result: TerminalFindResult) => void;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
TerminalInputModeTracker,
|
||||
terminalInputModeStatesEqual,
|
||||
@@ -119,8 +120,43 @@ function resolvePanResponderPoint(
|
||||
};
|
||||
}
|
||||
|
||||
function initialNativeTerminalSize(): TerminalMeasuredSize {
|
||||
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
|
||||
function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize {
|
||||
return {
|
||||
rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS,
|
||||
cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS,
|
||||
};
|
||||
}
|
||||
|
||||
function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState {
|
||||
const visibleRows = viewport.grid.length;
|
||||
const currentViewport = {
|
||||
firstRow: viewport.firstRow,
|
||||
lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1),
|
||||
};
|
||||
return {
|
||||
viewport,
|
||||
scroll: {
|
||||
mode: "following",
|
||||
firstRow: viewport.firstRow,
|
||||
visibleRows,
|
||||
oldestRow: viewport.oldestRow,
|
||||
newestRow: viewport.newestRow,
|
||||
currentViewport,
|
||||
bottomViewport: currentViewport,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState {
|
||||
return {
|
||||
rows: state.grid.length,
|
||||
cols: state.cols,
|
||||
firstRow: 0,
|
||||
oldestRow: 0,
|
||||
newestRow: Math.max(0, state.grid.length - 1),
|
||||
grid: state.grid,
|
||||
cursor: state.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
function NativeTerminalEmulator({
|
||||
@@ -133,6 +169,7 @@ function NativeTerminalEmulator({
|
||||
fontSize,
|
||||
keyboardInset = 0,
|
||||
isKeyboardVisible = false,
|
||||
initialSnapshot = null,
|
||||
onInput,
|
||||
onTerminalKey,
|
||||
onResize,
|
||||
@@ -144,18 +181,20 @@ function NativeTerminalEmulator({
|
||||
resizeRequestToken = 0,
|
||||
}: TerminalEmulatorProps) {
|
||||
const terminalRef = useRef<NativeHeadlessTerminal | null>(null);
|
||||
const mountInputRef = useRef({ streamKey });
|
||||
const mountInputRef = useRef({ streamKey, initialSnapshot });
|
||||
if (
|
||||
resolveNativeTerminalMountAction({
|
||||
mountedStreamKey: mountInputRef.current.streamKey,
|
||||
nextStreamKey: streamKey,
|
||||
}) === "mount"
|
||||
) {
|
||||
mountInputRef.current = { streamKey };
|
||||
mountInputRef.current = { streamKey, initialSnapshot };
|
||||
}
|
||||
const inputRef = useRef<TerminalInputHandle>(null);
|
||||
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize());
|
||||
const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize()));
|
||||
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize(initialSnapshot));
|
||||
const resizePolicyRef = useRef(
|
||||
createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)),
|
||||
);
|
||||
const activeResizeClaimTokenRef = useRef(0);
|
||||
const layoutRef = useRef<TerminalMeasuredLayout | null>(null);
|
||||
const metricsRef = useRef<TerminalGridCellMetrics | null>(null);
|
||||
@@ -181,7 +220,11 @@ function NativeTerminalEmulator({
|
||||
...inputModeTrackerRef.current.getState(),
|
||||
applicationCursorKeys: false,
|
||||
});
|
||||
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(null);
|
||||
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(() => {
|
||||
return initialSnapshot
|
||||
? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot))
|
||||
: null;
|
||||
});
|
||||
const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null);
|
||||
const selectionRangeRef = useRef<TerminalSelectionRange | null>(null);
|
||||
const hasNotifiedSelectionRef = useRef(false);
|
||||
@@ -318,14 +361,10 @@ function NativeTerminalEmulator({
|
||||
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
|
||||
createNativeTerminalOutputDrain({
|
||||
write: async (chunk) => {
|
||||
inputModeTrackerRef.current.feed(chunk);
|
||||
await terminal.write(chunk);
|
||||
emitInputModeChange();
|
||||
},
|
||||
restoreSize: () => terminal.resize(terminalSizeRef.current),
|
||||
reset: (size) => {
|
||||
inputModeTrackerRef.current.reset();
|
||||
if (size) terminal.resize(size);
|
||||
reset: () => {
|
||||
terminal.reset();
|
||||
emitInputModeChange();
|
||||
},
|
||||
@@ -379,7 +418,8 @@ function NativeTerminalEmulator({
|
||||
hasNotifiedMeasuredSizeRef.current = true;
|
||||
terminalSizeRef.current = policyResult.measuredSize;
|
||||
if (policyResult.measuredSizeChanged) {
|
||||
outputDrainRef.current?.resize();
|
||||
terminalRef.current?.resize(policyResult.measuredSize);
|
||||
schedulePaint();
|
||||
}
|
||||
callbacksRef.current.onResize?.({
|
||||
rows: policyResult.measuredSize.rows,
|
||||
@@ -388,51 +428,68 @@ function NativeTerminalEmulator({
|
||||
forceClaim: policyResult.resizeClaim?.force,
|
||||
});
|
||||
},
|
||||
[],
|
||||
[schedulePaint],
|
||||
);
|
||||
|
||||
const resetTerminal = useCallback(() => {
|
||||
outputDrainRef.current?.dispose();
|
||||
outputDrainRef.current = null;
|
||||
terminalRef.current?.dispose();
|
||||
screenModelRef.current = null;
|
||||
latestResolvedScreenRef.current = null;
|
||||
clearSelection();
|
||||
const measuredSize = resolveMeasuredNativeTerminalSize({
|
||||
layout: layoutRef.current,
|
||||
metrics: metricsRef.current,
|
||||
});
|
||||
const size = measuredSize ?? terminalSizeRef.current;
|
||||
terminalSizeRef.current = size;
|
||||
resizePolicyRef.current = createTerminalResizePolicy(size);
|
||||
outputDecoderRef.current.decode();
|
||||
const terminal = createNativeHeadlessTerminal({
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
const resetTerminal = useCallback(
|
||||
(snapshot: TerminalState | null) => {
|
||||
outputDrainRef.current?.dispose();
|
||||
outputDrainRef.current = null;
|
||||
terminalRef.current?.dispose();
|
||||
screenModelRef.current = null;
|
||||
latestResolvedScreenRef.current = null;
|
||||
clearSelection();
|
||||
const measuredSize = resolveMeasuredNativeTerminalSize({
|
||||
layout: layoutRef.current,
|
||||
metrics: metricsRef.current,
|
||||
});
|
||||
const size =
|
||||
measuredSize ??
|
||||
(snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current);
|
||||
terminalSizeRef.current = size;
|
||||
resizePolicyRef.current = createTerminalResizePolicy(size);
|
||||
outputDecoderRef.current.decode();
|
||||
const terminal = createNativeHeadlessTerminal({
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
scrollbackLines,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
|
||||
const outputDrain = createOutputDrain(terminal);
|
||||
outputDrainRef.current = outputDrain;
|
||||
const screen = snapshot
|
||||
? screenStateFromViewport(viewportStateFromSnapshot(snapshot))
|
||||
: resolveTerminalScreen(terminal);
|
||||
commitTerminalScreen(screen);
|
||||
resetInputModeTracker();
|
||||
if (snapshot) {
|
||||
outputDrain.restoreSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
[
|
||||
commitTerminalScreen,
|
||||
createOutputDrain,
|
||||
resetInputModeTracker,
|
||||
resolveTerminalScreen,
|
||||
scrollbackLines,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
|
||||
const outputDrain = createOutputDrain(terminal);
|
||||
outputDrainRef.current = outputDrain;
|
||||
const screen = resolveTerminalScreen(terminal);
|
||||
commitTerminalScreen(screen);
|
||||
resetInputModeTracker();
|
||||
}, [
|
||||
commitTerminalScreen,
|
||||
createOutputDrain,
|
||||
resetInputModeTracker,
|
||||
resolveTerminalScreen,
|
||||
scrollbackLines,
|
||||
clearSelection,
|
||||
]);
|
||||
clearSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const enqueueOutputText = useCallback((text: string) => {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
outputDrainRef.current?.enqueueText(text);
|
||||
}, []);
|
||||
const enqueueOutputText = useCallback(
|
||||
(text: string) => {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
const inputModeUpdate = inputModeTrackerRef.current.feed(text);
|
||||
if (inputModeUpdate.changed) {
|
||||
emitInputModeChange();
|
||||
}
|
||||
outputDrainRef.current?.enqueueText(text);
|
||||
},
|
||||
[emitInputModeChange],
|
||||
);
|
||||
|
||||
const claimActiveTerminalSize = useCallback(() => {
|
||||
activeResizeClaimTokenRef.current += 1;
|
||||
@@ -455,7 +512,27 @@ function NativeTerminalEmulator({
|
||||
const text = outputDecoderRef.current.decode(data, { stream: true });
|
||||
enqueueOutputText(text);
|
||||
},
|
||||
replay: (event) => outputDrainRef.current?.replay(event),
|
||||
restoreOutput: (data: TerminalOutputData) => {
|
||||
outputDecoderRef.current.decode();
|
||||
resetInputModeTracker();
|
||||
const text = outputDecoderRef.current.decode(data, { stream: false });
|
||||
outputDrainRef.current?.restoreText(text);
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
outputDecoderRef.current.decode();
|
||||
resetInputModeTracker();
|
||||
if (!state) {
|
||||
resetTerminal(null);
|
||||
return;
|
||||
}
|
||||
clearSelection();
|
||||
const outputDrain = outputDrainRef.current;
|
||||
if (!outputDrain) {
|
||||
resetTerminal(state);
|
||||
return;
|
||||
}
|
||||
outputDrain.restoreSnapshot(state);
|
||||
},
|
||||
paste: (text: string) => {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
@@ -486,6 +563,7 @@ function NativeTerminalEmulator({
|
||||
screenModelRef.current?.reset();
|
||||
clearSelection();
|
||||
resetInputModeTracker();
|
||||
terminalRef.current?.reset();
|
||||
schedulePaint();
|
||||
},
|
||||
claimSize: claimActiveTerminalSize,
|
||||
@@ -504,13 +582,14 @@ function NativeTerminalEmulator({
|
||||
claimActiveTerminalSizeForAction,
|
||||
clearSelection,
|
||||
resetInputModeTracker,
|
||||
resetTerminal,
|
||||
schedulePaint,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hasNotifiedMeasuredSizeRef.current = false;
|
||||
resetTerminal();
|
||||
resetTerminal(mountInputRef.current.initialSnapshot);
|
||||
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true });
|
||||
return () => {
|
||||
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { DOMProps } from "expo/dom";
|
||||
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import {
|
||||
@@ -39,7 +40,8 @@ import { focusWithRetries } from "../utils/web-focus";
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
writeOutput: (data: TerminalOutputData) => void;
|
||||
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
|
||||
restoreOutput: (data: TerminalOutputData) => void;
|
||||
renderSnapshot: (state: TerminalState | null) => void;
|
||||
paste: (text: string) => void;
|
||||
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
|
||||
clear: () => void;
|
||||
@@ -106,6 +108,7 @@ interface TerminalEmulatorProps {
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
initialSnapshot?: TerminalState | null;
|
||||
onFindRequest?: () => void;
|
||||
onFindResult?: (result: TerminalFindResult) => void;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
@@ -144,6 +147,16 @@ declare global {
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
function isTerminalState(value: unknown): value is TerminalState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"rows" in value &&
|
||||
"cols" in value &&
|
||||
"grid" in value
|
||||
);
|
||||
}
|
||||
|
||||
export default function TerminalEmulator({
|
||||
ref,
|
||||
streamKey,
|
||||
@@ -159,6 +172,7 @@ export default function TerminalEmulator({
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
@@ -214,6 +228,8 @@ export default function TerminalEmulator({
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
};
|
||||
const initialSnapshotRef = useRef(initialSnapshot);
|
||||
initialSnapshotRef.current = initialSnapshot;
|
||||
const pendingModifiersRef = useRef(pendingModifiers);
|
||||
pendingModifiersRef.current = pendingModifiers;
|
||||
|
||||
@@ -237,17 +253,17 @@ export default function TerminalEmulator({
|
||||
const data = args[0];
|
||||
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
|
||||
},
|
||||
replay: (event) => {
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
||||
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
|
||||
if (event.type === "data" && event.data instanceof Uint8Array)
|
||||
runtimeRef.current?.replay({ type: "data", data: event.data });
|
||||
if (event.type === "begin")
|
||||
runtimeRef.current?.replay({
|
||||
type: "begin",
|
||||
rows: typeof event.rows === "number" ? event.rows : undefined,
|
||||
cols: typeof event.cols === "number" ? event.cols : undefined,
|
||||
});
|
||||
restoreOutput: (...args) => {
|
||||
const data = args[0];
|
||||
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
|
||||
},
|
||||
renderSnapshot: (...args) => {
|
||||
const state = args[0];
|
||||
if (state === null) {
|
||||
runtimeRef.current?.renderSnapshot({ state: null });
|
||||
} else if (isTerminalState(state)) {
|
||||
runtimeRef.current?.renderSnapshot({ state });
|
||||
}
|
||||
},
|
||||
paste: (...args) => {
|
||||
const text = args[0];
|
||||
@@ -283,7 +299,12 @@ export default function TerminalEmulator({
|
||||
writeOutput: (data: TerminalOutputData) => {
|
||||
runtimeRef.current?.write({ data });
|
||||
},
|
||||
replay: (event) => runtimeRef.current?.replay(event),
|
||||
restoreOutput: (data: TerminalOutputData) => {
|
||||
runtimeRef.current?.restoreOutput({ data });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
runtimeRef.current?.renderSnapshot({ state });
|
||||
},
|
||||
paste: (text: string) => {
|
||||
pasteText(text);
|
||||
},
|
||||
@@ -450,6 +471,7 @@ export default function TerminalEmulator({
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
scrollback: scrollbackLinesRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
fontFamily: fontFamilyRef.current,
|
||||
|
||||
@@ -15,6 +15,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
|
||||
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
DEFAULT_TERMINAL_INPUT_MODE_STATE,
|
||||
type TerminalInputModeState,
|
||||
@@ -59,6 +60,7 @@ import { getDesktopHost } from "@/desktop/host";
|
||||
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
|
||||
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||
import {
|
||||
EMPTY_FOCUS_CLAIM_STATE,
|
||||
canRequestFocusClaim,
|
||||
@@ -86,6 +88,7 @@ import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
resolveTerminalStreamTarget,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
type TerminalRendererReadyChange,
|
||||
} from "@/utils/terminal-renderer-readiness";
|
||||
@@ -313,6 +316,10 @@ export function TerminalPane({
|
||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const workspaceTerminalSession = useMemo(
|
||||
() => getWorkspaceTerminalSession({ scopeKey }),
|
||||
[scopeKey],
|
||||
);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||
@@ -376,6 +383,7 @@ export function TerminalPane({
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
|
||||
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
|
||||
useEffect(() => {
|
||||
terminalIdRef.current = terminalId;
|
||||
@@ -433,8 +441,16 @@ export function TerminalPane({
|
||||
(change: TerminalRendererReadyChange) => {
|
||||
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
|
||||
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
|
||||
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
if (snapshot) {
|
||||
emulatorRef.current?.renderSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
[terminalStreamKey],
|
||||
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -548,6 +564,7 @@ export function TerminalPane({
|
||||
);
|
||||
|
||||
const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
|
||||
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
|
||||
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
});
|
||||
@@ -587,10 +604,29 @@ export function TerminalPane({
|
||||
},
|
||||
);
|
||||
|
||||
const handleStreamRestore = useStableEvent(
|
||||
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
|
||||
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
|
||||
if (terminalIdRef.current !== restoreTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.restoreOutput(data);
|
||||
},
|
||||
);
|
||||
|
||||
const handleStreamSnapshot = useStableEvent(
|
||||
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
|
||||
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
|
||||
if (terminalIdRef.current !== snapshotTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.renderSnapshot(state);
|
||||
},
|
||||
);
|
||||
|
||||
const getStreamRestoreOptions = useStableEvent(() =>
|
||||
resolveTerminalRestoreOptions({
|
||||
supportsTerminalRestoreModes,
|
||||
scrollbackLines: settings.terminalScrollbackLines,
|
||||
canClaimSize: canRequestFocusClaim({
|
||||
isWorkspaceFocused: terminalPresentedRef.current,
|
||||
isPaneFocused,
|
||||
@@ -617,9 +653,8 @@ export function TerminalPane({
|
||||
client,
|
||||
getPreferredSize: getPreferredStreamSize,
|
||||
onOutput: handleStreamOutput,
|
||||
onReplay: ({ terminalId: id, replay }) => {
|
||||
if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
|
||||
},
|
||||
onRestore: handleStreamRestore,
|
||||
onSnapshot: handleStreamSnapshot,
|
||||
onExit: handleStreamExit,
|
||||
getRestoreOptions: getStreamRestoreOptions,
|
||||
onStatusChange: handleStreamControllerStatus,
|
||||
@@ -639,6 +674,8 @@ export function TerminalPane({
|
||||
getStreamRestoreOptions,
|
||||
handleStreamControllerStatus,
|
||||
handleStreamOutput,
|
||||
handleStreamRestore,
|
||||
handleStreamSnapshot,
|
||||
handleStreamExit,
|
||||
isConnected,
|
||||
]);
|
||||
@@ -1179,6 +1216,7 @@ export function TerminalPane({
|
||||
keyboardInset={keyboardInset}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import React, { act, useEffect, useState, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { Text, View } from "react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index";
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* A real browser, because the regression lives in Reanimated's web entering path: it snapshots
|
||||
* the surface's rect when a custom keyframe ends and writes that rect back as inline
|
||||
* top/left/width/height when it cleans the keyframe up, 750ms after the animation starts.
|
||||
* Anything that moved or resized the surface in between is undone by that write.
|
||||
*/
|
||||
|
||||
interface Mounted {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
function mount(node: ReactNode): void {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(node));
|
||||
mounted.push({ root, container });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const OFFSET = 12;
|
||||
const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const;
|
||||
const SHORT: Size = { width: 200, height: 40 };
|
||||
const TALL: Size = { width: 200, height: 240 };
|
||||
const WIDE: Size = { width: 480, height: 40 };
|
||||
/** Inside the window between the entering animation ending and Reanimated's cleanup. */
|
||||
const GROWS_BEFORE_CLEANUP_MS = 400;
|
||||
/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */
|
||||
const GROWS_AFTER_CLEANUP_MS = 1_000;
|
||||
/** Long enough after the content grew for anything that would write to the surface to land. */
|
||||
const SETTLE_MS = 1_200;
|
||||
|
||||
/** Plain timers, not `act`: an async `act` holds every update until it resolves. */
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function element(testID: string): HTMLElement {
|
||||
const found = document.querySelector(`[data-testid="${testID}"]`);
|
||||
if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */
|
||||
function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) {
|
||||
const [grown, setGrown] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setGrown(true), afterMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [afterMs]);
|
||||
return <View testID="body" style={grown ? to : from} />;
|
||||
}
|
||||
|
||||
function mountMenuAbovePill(body: ReactNode): void {
|
||||
mount(
|
||||
<View style={PILL_POSITION}>
|
||||
<MenuRoot>
|
||||
<MenuTrigger testID="pill" accessibilityLabel="Open panel">
|
||||
<Text>Open panel</Text>
|
||||
</MenuTrigger>
|
||||
<MenuSurface
|
||||
side="top"
|
||||
align="start"
|
||||
offset={OFFSET}
|
||||
minWidth={280}
|
||||
maxWidth={620}
|
||||
testID="panel"
|
||||
>
|
||||
{body}
|
||||
</MenuSurface>
|
||||
</MenuRoot>
|
||||
</View>,
|
||||
);
|
||||
act(() => element("pill").click());
|
||||
}
|
||||
|
||||
describe("anchored menu surface on web", () => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(1280, 900);
|
||||
});
|
||||
|
||||
it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])(
|
||||
"stays on its anchor when its content grows %ims after opening",
|
||||
async (afterMs) => {
|
||||
mountMenuAbovePill(<GrowsAfterOpen afterMs={afterMs} from={SHORT} to={TALL} />);
|
||||
|
||||
await wait(afterMs + SETTLE_MS);
|
||||
|
||||
const pill = element("pill").getBoundingClientRect();
|
||||
const panel = element("panel").getBoundingClientRect();
|
||||
expect(panel.height).toBeGreaterThan(240);
|
||||
expect(panel.bottom).toBeLessThanOrEqual(pill.top);
|
||||
},
|
||||
);
|
||||
|
||||
it("widens when its content widens after the entering animation", async () => {
|
||||
mountMenuAbovePill(<GrowsAfterOpen afterMs={GROWS_BEFORE_CLEANUP_MS} from={SHORT} to={WIDE} />);
|
||||
|
||||
await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS);
|
||||
|
||||
const body = element("body").getBoundingClientRect();
|
||||
const panel = element("panel").getBoundingClientRect();
|
||||
expect(panel.width).toBeGreaterThanOrEqual(body.width);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactElement,
|
||||
@@ -13,6 +11,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
@@ -48,41 +47,56 @@ const contentEntering = new Keyframe({
|
||||
100: { opacity: 1, transform: [{ scale: 1 }] },
|
||||
}).duration(CONTENT_ENTERING_DURATION_MS);
|
||||
|
||||
/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */
|
||||
const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = {
|
||||
opacity: [0, 1],
|
||||
transform: ["scale(0.97)", "scale(1)"],
|
||||
};
|
||||
|
||||
/** Web plays the same keyframes itself; see `useWebContentEntering`. */
|
||||
const nativeContentEntering = isWeb ? undefined : contentEntering;
|
||||
|
||||
const contentExiting = new Keyframe({
|
||||
0: { opacity: 1, transform: [{ scale: 1 }] },
|
||||
100: { opacity: 0, transform: [{ scale: 0.97 }] },
|
||||
}).duration(100);
|
||||
|
||||
function releaseFixedMenuHeight(surfaceNativeID: string): void {
|
||||
if (!isWeb) return;
|
||||
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
|
||||
}
|
||||
|
||||
/**
|
||||
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
|
||||
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
|
||||
* up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
|
||||
* moved or resized in between — content that grew once its data arrived — snapped back to the old
|
||||
* rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
|
||||
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
|
||||
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
|
||||
* a submenu page taller than the page it replaced is not clipped to the old height.
|
||||
*
|
||||
* `revision` is what makes that second case work: bump it whenever the rendered content changes
|
||||
* identity (a page push, for instance) and the snapshot is released again.
|
||||
*/
|
||||
function useWebContentEntering({
|
||||
placed,
|
||||
function useReleaseFixedMenuHeight({
|
||||
contentSize,
|
||||
enabled,
|
||||
surfaceNativeID,
|
||||
revision,
|
||||
}: {
|
||||
placed: boolean;
|
||||
contentSize: Size | null;
|
||||
enabled: boolean;
|
||||
surfaceNativeID: string;
|
||||
revision?: string | number;
|
||||
}): void {
|
||||
useLayoutEffect(() => {
|
||||
if (!isWeb || !placed || typeof document === "undefined") return undefined;
|
||||
const animation = document
|
||||
.getElementById(surfaceNativeID)
|
||||
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
|
||||
return () => animation?.cancel();
|
||||
}, [placed, surfaceNativeID]);
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
const release = () => {
|
||||
releaseFixedMenuHeight(surfaceNativeID);
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
|
||||
}
|
||||
};
|
||||
const timers: ReturnType<typeof setTimeout>[] = [
|
||||
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
|
||||
];
|
||||
|
||||
if (contentSize) {
|
||||
timers.push(setTimeout(release, 0));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
};
|
||||
}, [contentSize, enabled, surfaceNativeID, revision]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +224,8 @@ export interface AnchoredSurfaceProps {
|
||||
fullWidth?: boolean;
|
||||
horizontalPadding?: number;
|
||||
scrollable?: boolean;
|
||||
/** Bump when the rendered content changes identity, so the height snapshot is released. */
|
||||
revision?: string | number;
|
||||
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
|
||||
backdrop?: boolean;
|
||||
/**
|
||||
@@ -243,6 +259,7 @@ export function AnchoredSurface({
|
||||
fullWidth = false,
|
||||
horizontalPadding = 16,
|
||||
scrollable = false,
|
||||
revision,
|
||||
backdrop = true,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
@@ -252,25 +269,33 @@ export function AnchoredSurface({
|
||||
}: AnchoredSurfaceProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const surfaceNativeID = useId();
|
||||
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
|
||||
open,
|
||||
anchorRect,
|
||||
anchorRef,
|
||||
side,
|
||||
align,
|
||||
offset,
|
||||
scrollable,
|
||||
maxHeight,
|
||||
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
|
||||
useAnchoredPosition({
|
||||
open,
|
||||
anchorRect,
|
||||
anchorRef,
|
||||
side,
|
||||
align,
|
||||
offset,
|
||||
scrollable,
|
||||
maxHeight,
|
||||
});
|
||||
|
||||
useReleaseFixedMenuHeight({
|
||||
contentSize,
|
||||
enabled: open,
|
||||
surfaceNativeID,
|
||||
revision,
|
||||
});
|
||||
|
||||
// The surface is placed once both measurements land. Until then it sits off-screen so it can
|
||||
// be measured, and it must not animate: an entering animation that ran there would be over
|
||||
// before anyone saw the menu. Remounting on placement starts the native animation from the
|
||||
// final position; web starts its own animation on placement.
|
||||
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's
|
||||
// rect when the animation ends and writes that rect back as inline top/left when it cleans the
|
||||
// keyframe up 750ms later. If the animation ends before placement, the snapshot is the
|
||||
// off-screen rect and the menu jumps back off-screen. Remounting on placement starts the
|
||||
// animation from the final position.
|
||||
const placed = position !== null;
|
||||
|
||||
useWebContentEntering({ placed, surfaceNativeID });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
@@ -358,7 +383,7 @@ export function AnchoredSurface({
|
||||
dataSet={surfaceDataSet}
|
||||
style={styles.content}
|
||||
frameStyle={frameStyle}
|
||||
entering={placed ? nativeContentEntering : undefined}
|
||||
entering={placed ? contentEntering : undefined}
|
||||
exiting={
|
||||
!placed || isWeb || !onExited
|
||||
? undefined
|
||||
|
||||
@@ -344,6 +344,7 @@ function MenuFlyout({
|
||||
maxHeight={maxHeight}
|
||||
scrollable={scrollable}
|
||||
backdrop={false}
|
||||
revision={page.id}
|
||||
onPointerEnter={cancelHoverClose}
|
||||
onPointerLeave={handleHoverOut}
|
||||
testID={testID}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Modal as NativeModal, type ModalProps } from "react-native";
|
||||
|
||||
// React Native defaults iPhone modals to portrait even when the app supports rotation.
|
||||
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
|
||||
"portrait",
|
||||
"portrait-upside-down",
|
||||
"landscape-left",
|
||||
"landscape-right",
|
||||
];
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
return <NativeModal supportedOrientations={orientations} {...props} />;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
@@ -17,6 +16,7 @@ import { createPortal } from "react-dom";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Modal,
|
||||
Pressable,
|
||||
View,
|
||||
type PressableProps,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
|
||||
@@ -617,7 +617,6 @@ export const ar: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "Host غير متصل",
|
||||
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
||||
restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.",
|
||||
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
||||
fileUploaded: "تم رفع الملف",
|
||||
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
|
||||
@@ -2158,7 +2157,6 @@ export const ar: TranslationResources = {
|
||||
minutes_few: "{{value}} دقائق",
|
||||
minutes_many: "{{value}} دقيقة",
|
||||
minutes_other: "{{value}} دقيقة",
|
||||
pair: "{{first}} و{{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "آخر تحديث {{relativeTime}}",
|
||||
@@ -2166,8 +2164,6 @@ export const ar: TranslationResources = {
|
||||
resetTimePassed: "مضى وقت إعادة التعيين",
|
||||
resettingNow: "تتم إعادة التعيين الآن",
|
||||
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}، {{time}}",
|
||||
runsOutNow: "ينفد الآن",
|
||||
runsOutIn: "ينفد خلال {{duration}}",
|
||||
justNow: "الآن",
|
||||
@@ -2782,14 +2778,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
resumeOnStartup: {
|
||||
title: "استئناف الوكلاء بعد إعادة التشغيل",
|
||||
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
|
||||
hint: "متابعة العمل المنقطع تلقائيًا.",
|
||||
prompt: "طلب الاستئناف",
|
||||
edit: "تحرير",
|
||||
editAccessibilityLabel: "تعديل الطلب",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "تحرير الوكلاء الخاملين",
|
||||
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
||||
@@ -2885,10 +2873,6 @@ export const ar: TranslationResources = {
|
||||
requestFailedTitle: "خطأ",
|
||||
requestFailedMessage:
|
||||
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
|
||||
closeTerminals: "إعادة التشغيل وإغلاق الطرفيات",
|
||||
closeTerminalsHint: "يعيد التشغيل ويغلق أيضًا كل الطرفيات المفتوحة على هذا المضيف.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"سيُعاد تشغيل الخدمة وتُغلق كل طرفياتها، مما ينهي البرامج التي تعمل فيها. سيعيد التطبيق الاتصال تلقائيًا.",
|
||||
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -614,7 +614,6 @@ export const en = {
|
||||
terminal: {
|
||||
hostDisconnected: "Host is not connected",
|
||||
updateHost: "Update the host to use the native terminal renderer.",
|
||||
restoreRequiresUpdate: "Update the host to view terminals.",
|
||||
unableToSubscribe: "Unable to subscribe to terminal",
|
||||
fileUploaded: "File uploaded",
|
||||
pasteFileFailed: "Failed to paste file: {{reason}}",
|
||||
@@ -2165,18 +2164,15 @@ export const en = {
|
||||
days: "{{value}}d",
|
||||
hours: "{{value}}h",
|
||||
minutes: "{{value}}m",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Last updated {{relativeTime}}",
|
||||
noSuccessfulUpdate: "No successful update",
|
||||
resetTimePassed: "Reset time passed",
|
||||
resettingNow: "resetting now",
|
||||
resetsIn: "resets in {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
resetsIn: "resets {{duration}}",
|
||||
runsOutNow: "runs out now",
|
||||
runsOutIn: "runs out in {{duration}}",
|
||||
runsOutIn: "runs out {{duration}}",
|
||||
justNow: "just now",
|
||||
daysAgo: "{{value}}d ago",
|
||||
hoursAgo: "{{value}}h ago",
|
||||
@@ -2874,14 +2870,6 @@ export const en = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
resumeOnStartup: {
|
||||
title: "Resume agents after restart",
|
||||
info: "Reopen agents that were working when the host stopped and send the resume prompt. Applies after graceful restarts and crashes.",
|
||||
hint: "Continue interrupted work automatically.",
|
||||
prompt: "Resume prompt",
|
||||
edit: "Edit",
|
||||
editAccessibilityLabel: "Edit prompt",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "Release idle agents",
|
||||
hint: "Free up memory after an hour of inactivity.",
|
||||
@@ -2978,10 +2966,6 @@ export const en = {
|
||||
requestFailedTitle: "Error",
|
||||
requestFailedMessage:
|
||||
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
|
||||
closeTerminals: "Restart and close terminals",
|
||||
closeTerminalsHint: "Restart and also close every terminal running on this host.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"This will restart the daemon and close every terminal on it, ending the programs running in them. The app will reconnect automatically.",
|
||||
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -623,7 +623,6 @@ export const es: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "Hostno está conectado",
|
||||
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
|
||||
restoreRequiresUpdate: "Actualiza el host para ver las terminales.",
|
||||
unableToSubscribe: "No se puede suscribir al terminal",
|
||||
fileUploaded: "Archivo subido",
|
||||
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
|
||||
@@ -2188,7 +2187,6 @@ export const es: TranslationResources = {
|
||||
days: "{{value}} d",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Última actualización {{relativeTime}}",
|
||||
@@ -2196,8 +2194,6 @@ export const es: TranslationResources = {
|
||||
resetTimePassed: "Pasó la hora de restablecimiento",
|
||||
resettingNow: "se restablece ahora",
|
||||
resetsIn: "se restablece en {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "se agota ahora",
|
||||
runsOutIn: "se agota en {{duration}}",
|
||||
justNow: "ahora mismo",
|
||||
@@ -2804,14 +2800,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
resumeOnStartup: {
|
||||
title: "Reanudar agentes tras reiniciar",
|
||||
info: "Vuelve a abrir los agentes que estaban trabajando cuando se detuvo el host y envía el mensaje de reanudación. Se aplica tras reinicios normales y fallos.",
|
||||
hint: "Continuar automáticamente el trabajo interrumpido.",
|
||||
prompt: "Mensaje de reanudación",
|
||||
edit: "Editar",
|
||||
editAccessibilityLabel: "Editar mensaje",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "Liberar agentes inactivos",
|
||||
hint: "Libera memoria tras una hora de inactividad.",
|
||||
@@ -2909,11 +2897,6 @@ export const es: TranslationResources = {
|
||||
requestFailedTitle: "Error",
|
||||
requestFailedMessage:
|
||||
"No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.",
|
||||
closeTerminals: "Reiniciar y cerrar terminales",
|
||||
closeTerminalsHint:
|
||||
"Reinicia y además cierra todas las terminales abiertas en este host.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Esto reiniciará el daemon y cerrará todas sus terminales, terminando los programas que se ejecutan en ellas. La app se reconectará automáticamente.",
|
||||
dialogFailedMessage:
|
||||
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
|
||||
},
|
||||
|
||||
@@ -623,7 +623,6 @@ export const fr: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "L’hôte n’est pas connecté",
|
||||
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
|
||||
restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.",
|
||||
unableToSubscribe: "Impossible de s’abonner au terminal",
|
||||
fileUploaded: "Fichier envoyé",
|
||||
pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
|
||||
@@ -2188,7 +2187,6 @@ export const fr: TranslationResources = {
|
||||
days: "{{value}} j",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Dernière mise à jour {{relativeTime}}",
|
||||
@@ -2196,8 +2194,6 @@ export const fr: TranslationResources = {
|
||||
resetTimePassed: "Heure de réinitialisation dépassée",
|
||||
resettingNow: "réinitialisation en cours",
|
||||
resetsIn: "se réinitialise dans {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "s’épuise maintenant",
|
||||
runsOutIn: "s’épuise dans {{duration}}",
|
||||
justNow: "à l’instant",
|
||||
@@ -2807,14 +2803,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
|
||||
resumeOnStartup: {
|
||||
title: "Reprendre les agents après un redémarrage",
|
||||
info: "Rouvre les agents qui travaillaient lorsque l’hôte s’est arrêté et envoie le message de reprise. S’applique après un redémarrage normal ou un plantage.",
|
||||
hint: "Continuer automatiquement le travail interrompu.",
|
||||
prompt: "Message de reprise",
|
||||
edit: "Modifier",
|
||||
editAccessibilityLabel: "Modifier le message",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "Libérer les agents inactifs",
|
||||
hint: "Libère de la mémoire après une heure d’inactivité.",
|
||||
@@ -2914,10 +2902,6 @@ export const fr: TranslationResources = {
|
||||
requestFailedTitle: "Erreur",
|
||||
requestFailedMessage:
|
||||
"Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.",
|
||||
closeTerminals: "Redémarrer et fermer les terminaux",
|
||||
closeTerminalsHint: "Redémarre et ferme aussi tous les terminaux ouverts sur cet hôte.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Le daemon va redémarrer et fermer tous ses terminaux, ce qui arrêtera les programmes qui y tournent. L'app se reconnectera automatiquement.",
|
||||
dialogFailedMessage:
|
||||
"Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.",
|
||||
},
|
||||
|
||||
@@ -623,7 +623,6 @@ export const ja: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "ホストが接続されていません",
|
||||
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
||||
restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。",
|
||||
unableToSubscribe: "ターミナルに接続できません",
|
||||
fileUploaded: "ファイルをアップロードしました",
|
||||
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
|
||||
@@ -2161,7 +2160,6 @@ export const ja: TranslationResources = {
|
||||
days: "{{value}}日",
|
||||
hours: "{{value}}時間",
|
||||
minutes: "{{value}}分",
|
||||
pair: "{{first}}{{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "最終更新: {{relativeTime}}",
|
||||
@@ -2169,8 +2167,6 @@ export const ja: TranslationResources = {
|
||||
resetTimePassed: "リセット時刻を過ぎています",
|
||||
resettingNow: "まもなくリセット",
|
||||
resetsIn: "{{duration}}後にリセット",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}} {{time}}",
|
||||
runsOutNow: "まもなく上限に到達",
|
||||
runsOutIn: "{{duration}}後に上限に到達",
|
||||
justNow: "たった今",
|
||||
@@ -2771,14 +2767,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
||||
resumeOnStartup: {
|
||||
title: "再起動後にエージェントを再開",
|
||||
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
|
||||
hint: "中断された作業を自動的に続行します。",
|
||||
prompt: "再開メッセージ",
|
||||
edit: "編集",
|
||||
editAccessibilityLabel: "メッセージを編集",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "アイドル状態のエージェントを解放",
|
||||
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
||||
@@ -2877,10 +2865,6 @@ export const ja: TranslationResources = {
|
||||
requestFailedTitle: "エラー",
|
||||
requestFailedMessage:
|
||||
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
|
||||
closeTerminals: "再起動してターミナルを閉じる",
|
||||
closeTerminalsHint: "再起動し、このホストのすべてのターミナルも閉じます。",
|
||||
closeTerminalsConfirmMessage:
|
||||
"デーモンを再起動し、すべてのターミナルを閉じます。ターミナルで実行中のプログラムは終了します。アプリは自動的に再接続します。",
|
||||
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -618,7 +618,6 @@ export const ko: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "호스트가 연결되어 있지 않습니다",
|
||||
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
|
||||
restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.",
|
||||
unableToSubscribe: "터미널을 구독할 수 없습니다",
|
||||
fileUploaded: "파일을 업로드했습니다",
|
||||
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
|
||||
@@ -2150,7 +2149,6 @@ export const ko: TranslationResources = {
|
||||
days: "{{value}}일",
|
||||
hours: "{{value}}시간",
|
||||
minutes: "{{value}}분",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "마지막 업데이트: {{relativeTime}}",
|
||||
@@ -2158,8 +2156,6 @@ export const ko: TranslationResources = {
|
||||
resetTimePassed: "초기화 시간이 지났습니다",
|
||||
resettingNow: "지금 재설정 중",
|
||||
resetsIn: "{{duration}} 후 재설정",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}} {{time}}",
|
||||
runsOutNow: "지금 한도에 도달",
|
||||
runsOutIn: "{{duration}} 후 한도에 도달",
|
||||
justNow: "방금",
|
||||
@@ -2759,14 +2755,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
||||
resumeOnStartup: {
|
||||
title: "재시작 후 에이전트 재개",
|
||||
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
|
||||
hint: "중단된 작업을 자동으로 계속합니다.",
|
||||
prompt: "재개 메시지",
|
||||
edit: "편집",
|
||||
editAccessibilityLabel: "메시지 편집",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "유휴 에이전트 해제",
|
||||
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
||||
@@ -2864,10 +2852,6 @@ export const ko: TranslationResources = {
|
||||
requestFailedTitle: "오류",
|
||||
requestFailedMessage:
|
||||
"재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.",
|
||||
closeTerminals: "재시작하고 터미널 닫기",
|
||||
closeTerminalsHint: "재시작하면서 이 호스트의 모든 터미널도 닫습니다.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"데몬을 재시작하고 모든 터미널을 닫습니다. 터미널에서 실행 중인 프로그램은 종료됩니다. 앱은 자동으로 다시 연결됩니다.",
|
||||
dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -622,7 +622,6 @@ export const ptBR: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "Host não está conectado",
|
||||
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
|
||||
restoreRequiresUpdate: "Atualize o host para visualizar os terminais.",
|
||||
unableToSubscribe: "Não foi possível assinar o terminal",
|
||||
fileUploaded: "Arquivo enviado",
|
||||
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
|
||||
@@ -2173,7 +2172,6 @@ export const ptBR: TranslationResources = {
|
||||
days: "{{value}} d",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Última atualização {{relativeTime}}",
|
||||
@@ -2181,8 +2179,6 @@ export const ptBR: TranslationResources = {
|
||||
resetTimePassed: "O horário de redefinição passou",
|
||||
resettingNow: "redefinindo agora",
|
||||
resetsIn: "redefine em {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "esgota agora",
|
||||
runsOutIn: "esgota em {{duration}}",
|
||||
justNow: "agora mesmo",
|
||||
@@ -2786,14 +2782,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Conecte-se a este host para gerenciar agentes",
|
||||
resumeOnStartup: {
|
||||
title: "Retomar agentes após reiniciar",
|
||||
info: "Reabre os agentes que estavam trabalhando quando o host parou e envia a mensagem de retomada. Aplica-se após reinicializações normais e falhas.",
|
||||
hint: "Continuar automaticamente o trabalho interrompido.",
|
||||
prompt: "Mensagem de retomada",
|
||||
edit: "Editar",
|
||||
editAccessibilityLabel: "Editar mensagem",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "Liberar agentes ociosos",
|
||||
hint: "Libera memória após uma hora de inatividade.",
|
||||
@@ -2892,10 +2880,6 @@ export const ptBR: TranslationResources = {
|
||||
requestFailedTitle: "Erro",
|
||||
requestFailedMessage:
|
||||
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
|
||||
closeTerminals: "Reiniciar e fechar terminais",
|
||||
closeTerminalsHint: "Reinicia e também fecha todos os terminais abertos neste host.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Isso reiniciará o daemon e fechará todos os terminais dele, encerrando os programas que rodam neles. O app vai se reconectar automaticamente.",
|
||||
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -624,7 +624,6 @@ export const ru: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "Хост не подключён",
|
||||
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
|
||||
restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.",
|
||||
unableToSubscribe: "Не удалось подключиться к терминалу",
|
||||
fileUploaded: "Файл загружен",
|
||||
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
|
||||
@@ -2173,7 +2172,6 @@ export const ru: TranslationResources = {
|
||||
days: "{{value}} дн.",
|
||||
hours: "{{value}} ч",
|
||||
minutes: "{{value}} мин",
|
||||
pair: "{{first}} {{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Последнее обновление {{relativeTime}}",
|
||||
@@ -2181,8 +2179,6 @@ export const ru: TranslationResources = {
|
||||
resetTimePassed: "Время сброса прошло",
|
||||
resettingNow: "сброс сейчас",
|
||||
resetsIn: "сброс через {{duration}}",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}}, {{time}}",
|
||||
runsOutNow: "закончится сейчас",
|
||||
runsOutIn: "закончится через {{duration}}",
|
||||
justNow: "только что",
|
||||
@@ -2796,14 +2792,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
||||
resumeOnStartup: {
|
||||
title: "Возобновлять агентов после перезапуска",
|
||||
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
|
||||
hint: "Автоматически продолжать прерванную работу.",
|
||||
prompt: "Сообщение для продолжения",
|
||||
edit: "Редактировать",
|
||||
editAccessibilityLabel: "Изменить сообщение",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "Освобождать неактивных агентов",
|
||||
hint: "Освобождает память после часа бездействия.",
|
||||
@@ -2902,10 +2890,6 @@ export const ru: TranslationResources = {
|
||||
requestFailedTitle: "Ошибка",
|
||||
requestFailedMessage:
|
||||
"Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.",
|
||||
closeTerminals: "Перезапустить и закрыть терминалы",
|
||||
closeTerminalsHint: "Перезапускает демон и закрывает все терминалы на этом хосте.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Демон будет перезапущен, а все его терминалы закрыты вместе с запущенными в них программами. Приложение переподключится автоматически.",
|
||||
dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -617,7 +617,6 @@ export const zhCN: TranslationResources = {
|
||||
terminal: {
|
||||
hostDisconnected: "Host 未连接",
|
||||
updateHost: "请更新主机以使用原生终端渲染器。",
|
||||
restoreRequiresUpdate: "更新主机以查看终端。",
|
||||
unableToSubscribe: "无法订阅 Terminal",
|
||||
fileUploaded: "文件已上传",
|
||||
pasteFileFailed: "无法粘贴文件:{{reason}}",
|
||||
@@ -2118,7 +2117,6 @@ export const zhCN: TranslationResources = {
|
||||
days: "{{value}}天",
|
||||
hours: "{{value}}小时",
|
||||
minutes: "{{value}}分钟",
|
||||
pair: "{{first}}{{second}}",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "上次更新于 {{relativeTime}}",
|
||||
@@ -2126,8 +2124,6 @@ export const zhCN: TranslationResources = {
|
||||
resetTimePassed: "重置时间已过",
|
||||
resettingNow: "正在重置",
|
||||
resetsIn: "将在 {{duration}}后重置",
|
||||
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||
resetsAtDate: "{{date}} {{time}}",
|
||||
runsOutNow: "即将用尽",
|
||||
runsOutIn: "将在 {{duration}}后用尽",
|
||||
justNow: "刚刚",
|
||||
@@ -2717,14 +2713,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "连接到这个 Host 以管理 Agent",
|
||||
resumeOnStartup: {
|
||||
title: "重启后恢复智能体",
|
||||
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
|
||||
hint: "自动继续被中断的工作。",
|
||||
prompt: "恢复提示",
|
||||
edit: "编辑",
|
||||
editAccessibilityLabel: "编辑提示",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "释放空闲 Agent",
|
||||
hint: "空闲一小时后释放内存。",
|
||||
@@ -2816,10 +2804,6 @@ export const zhCN: TranslationResources = {
|
||||
offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。",
|
||||
requestFailedTitle: "错误",
|
||||
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
|
||||
closeTerminals: "重启并关闭终端",
|
||||
closeTerminalsHint: "重启的同时关闭此主机上的所有终端。",
|
||||
closeTerminalsConfirmMessage:
|
||||
"这将重启守护进程并关闭其所有终端,终端中运行的程序会被结束。应用会自动重新连接。",
|
||||
dialogFailedMessage: "无法打开重启确认对话框。",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
import appPackage from "../../package.json";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import { PluginRegistry } from "./registry";
|
||||
import { pluginSettingsKey } from "./settings/query";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
const rpc = settingsRpc("preferences");
|
||||
type Catalog = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>;
|
||||
type Observation = ReturnType<DaemonClient["observeEvents"]>;
|
||||
type Observer = Parameters<Observation["subscribe"]>[0];
|
||||
|
||||
class SettingsStore {
|
||||
private revision = 1;
|
||||
private values = { label: "original" };
|
||||
reads = 0;
|
||||
|
||||
read() {
|
||||
this.reads++;
|
||||
return { status: "ready", revision: String(this.revision), values: this.values } as const;
|
||||
}
|
||||
|
||||
write(input: unknown) {
|
||||
const write = rpc.write.input.parse(input);
|
||||
if (write.revision !== String(this.revision)) {
|
||||
return { status: "conflict", error: "Settings changed" } as const;
|
||||
}
|
||||
const values = write.values;
|
||||
if (typeof values !== "object" || values === null || !("label" in values)) {
|
||||
throw new Error("Expected settings values");
|
||||
}
|
||||
if (typeof values.label !== "string") throw new Error("Expected label");
|
||||
this.values = { label: values.label };
|
||||
this.revision++;
|
||||
return { status: "saved", revision: String(this.revision), values: this.values } as const;
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory daemon port; it delivers owned snapshots and stops updates on release. */
|
||||
class CatalogClient extends DaemonClient {
|
||||
catalog = catalog("one");
|
||||
fetches = 0;
|
||||
releases = 0;
|
||||
events: Parameters<DaemonClient["observeEvents"]>[0] = [];
|
||||
private readonly observers = new Set<Observer>();
|
||||
|
||||
constructor(readonly settings = new SettingsStore()) {
|
||||
super({ url: "ws://unused.test", clientId: "catalog-sync-test" });
|
||||
}
|
||||
|
||||
override async getPluginCatalog(): Promise<Catalog> {
|
||||
this.fetches++;
|
||||
return this.catalog;
|
||||
}
|
||||
|
||||
override observeEvents(events: Parameters<DaemonClient["observeEvents"]>[0]): Observation {
|
||||
this.events = events;
|
||||
const snapshot = { requestId: "events", subscriptionId: "catalog" };
|
||||
const subscribers = new Set<Observer>();
|
||||
return {
|
||||
subscriptionId: snapshot.subscriptionId,
|
||||
ready: Promise.resolve(snapshot),
|
||||
subscribe: (observer) => {
|
||||
subscribers.add(observer);
|
||||
this.observers.add(observer);
|
||||
observer.snapshot(snapshot);
|
||||
return () => {
|
||||
subscribers.delete(observer);
|
||||
this.observers.delete(observer);
|
||||
};
|
||||
},
|
||||
release: async () => {
|
||||
this.releases++;
|
||||
for (const observer of subscribers) this.observers.delete(observer);
|
||||
subscribers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
override async invokePluginRpc(_pluginId: string, method: string, input: unknown) {
|
||||
if (method === rpc.read.name) return this.settings.read();
|
||||
if (method === rpc.write.name) return this.settings.write(input);
|
||||
throw new Error(`Unexpected RPC: ${method}`);
|
||||
}
|
||||
|
||||
update(message: SessionOutboundMessage) {
|
||||
for (const observer of this.observers) observer.update(message);
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
for (const observer of this.observers) {
|
||||
observer.snapshot({ requestId: "reconnect", subscriptionId: "catalog" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function catalog(marker: string): Catalog {
|
||||
return [
|
||||
{
|
||||
id: "reports",
|
||||
requirements: { paseo: `>=${appPackage.version}` },
|
||||
clientBundle: `(function() { return { default: function(plugin) {
|
||||
plugin.addTimelineTransformer({
|
||||
id: "report", query: { itemType: "tool_call" },
|
||||
transform() { return ${JSON.stringify(marker)} ? { items: [] } : undefined; }
|
||||
});
|
||||
return function() { plugin.openSettings("cleanup"); };
|
||||
} }; })`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) cleanup();
|
||||
});
|
||||
|
||||
function harness() {
|
||||
let evaluations = 0;
|
||||
let disposals = 0;
|
||||
const registry = new PluginRegistry({
|
||||
localeSource: { getLocale: () => "en", subscribe: () => () => {} },
|
||||
version: appPackage.version,
|
||||
createRuntime(installation) {
|
||||
evaluations++;
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected runtime RPC");
|
||||
},
|
||||
openSettings: () => {
|
||||
disposals++;
|
||||
},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
async playAudio() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
};
|
||||
},
|
||||
});
|
||||
const client = new CatalogClient();
|
||||
cleanups.push(() => {
|
||||
registry.removeHost("host");
|
||||
registry.removeHost("other");
|
||||
});
|
||||
function sync(options: Partial<Parameters<typeof syncPluginCatalog>[0]> = {}) {
|
||||
const stop = syncPluginCatalog({
|
||||
serverId: "host",
|
||||
client,
|
||||
audio,
|
||||
connected: true,
|
||||
supported: true,
|
||||
registry,
|
||||
...options,
|
||||
});
|
||||
cleanups.push(stop);
|
||||
return stop;
|
||||
}
|
||||
function installed(serverId = "host") {
|
||||
const plugin = registry.getSnapshot().find((entry) => entry.serverId === serverId);
|
||||
if (!plugin) throw new Error(`Missing installation: ${serverId}`);
|
||||
return plugin;
|
||||
}
|
||||
return {
|
||||
registry,
|
||||
client,
|
||||
sync,
|
||||
installed,
|
||||
evaluations: () => evaluations,
|
||||
disposals: () => disposals,
|
||||
};
|
||||
}
|
||||
|
||||
async function connected() {
|
||||
const h = harness();
|
||||
const stop = h.sync();
|
||||
await expect.poll(() => h.registry.getSnapshot().length).toBe(1);
|
||||
return { ...h, stop };
|
||||
}
|
||||
|
||||
function settingsQuery(plugin: InstalledPlugin) {
|
||||
return {
|
||||
queryKey: pluginSettingsKey("preferences"),
|
||||
queryFn: async () => rpc.read.output.parse(await plugin.invoke(rpc.read.name, {})),
|
||||
// The replica policy used by useSettings deliberately disables automatic refetches.
|
||||
gcTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
it("retains installations and timeline transforms while disconnected", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
const transformers = plugin.timelineTransformers;
|
||||
h.stop();
|
||||
h.sync({ connected: false });
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||
});
|
||||
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||
expect(h.installed().timelineTransformers).toBe(transformers);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(false);
|
||||
expect(h.disposals()).toBe(0);
|
||||
expect(h.client.fetches).toBe(1);
|
||||
expect(h.client.events).toEqual([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["mounted", "unmounted"])(
|
||||
"refreshes %s settings and revisions after another client saves offline",
|
||||
async (surface) => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
const options = settingsQuery(plugin);
|
||||
const original = await plugin.queryClient.fetchQuery(options);
|
||||
const observer = new QueryObserver(plugin.queryClient, options);
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
cleanups.push(unsubscribe);
|
||||
if (surface === "unmounted") unsubscribe();
|
||||
plugin.queryClient.setQueryData(["attachment-search"], "retained");
|
||||
const otherClient = new CatalogClient();
|
||||
h.registry.installCatalog("other", otherClient.catalog, { client: otherClient, audio });
|
||||
const otherPlugin = h.installed("other");
|
||||
await otherPlugin.queryClient.fetchQuery(settingsQuery(otherPlugin));
|
||||
// Another host publishes new presentation snapshots without replacing the owning lifetime.
|
||||
const beforeReconnect = h.registry.getSnapshot();
|
||||
const retainedInstallation = h.installed();
|
||||
|
||||
h.stop();
|
||||
h.sync({ connected: false });
|
||||
const clientB = new CatalogClient(h.client.settings);
|
||||
expect(
|
||||
await clientB.invokePluginRpc("reports", rpc.write.name, {
|
||||
revision: original.revision,
|
||||
values: { label: "client B" },
|
||||
}),
|
||||
).toEqual({ status: "saved", revision: "2", values: { label: "client B" } });
|
||||
expect(plugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||
h.sync();
|
||||
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "client B" },
|
||||
});
|
||||
const refreshed = await plugin.queryClient.fetchQuery(options);
|
||||
expect(
|
||||
await plugin.invoke(rpc.write.name, {
|
||||
revision: refreshed.revision,
|
||||
values: { label: "client A" },
|
||||
}),
|
||||
).toEqual({ status: "saved", revision: "3", values: { label: "client A" } });
|
||||
expect(h.registry.getSnapshot()).toBe(beforeReconnect);
|
||||
expect(h.installed()).toBe(retainedInstallation);
|
||||
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||
expect(h.installed().timelineTransformers).toBe(snapshot[0].timelineTransformers);
|
||||
expect(plugin.queryClient.getQueryData(["attachment-search"])).toBe("retained");
|
||||
expect(otherClient.settings.reads).toBe(1);
|
||||
expect(otherPlugin.queryClient.getQueryState(options.queryKey)?.isInvalidated).toBe(false);
|
||||
expect(otherPlugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.disposals()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("replaces changed bundles on reconnect and disposes the old installation", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
plugin.queryClient.setQueryData(["attachment-search"], "old");
|
||||
h.stop();
|
||||
h.client.catalog = catalog("two");
|
||||
h.sync();
|
||||
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(h.installed().timelineTransformers.map((entry) => entry.id)).toEqual(["report"]);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.disposals()).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces an explicitly reloaded bundle even when its source is unchanged", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||
});
|
||||
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||
expect(h.disposals()).toBe(1);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["unsupported", "no audio"])("disposes plugins when %s", async (reason) => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
h.stop();
|
||||
h.sync({ supported: reason !== "unsupported", audio: reason === "no audio" ? null : audio });
|
||||
expect(h.registry.getSnapshot()).toEqual([]);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||
expect(h.disposals()).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes settings on a replayed subscription snapshot without reinstalling", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const options = settingsQuery(plugin);
|
||||
await plugin.queryClient.fetchQuery(options);
|
||||
h.client.settings.write({ revision: "1", values: { label: "replayed" } });
|
||||
h.client.snapshot();
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "replayed" },
|
||||
});
|
||||
expect(h.installed()).toBe(plugin);
|
||||
expect(h.evaluations()).toBe(1);
|
||||
});
|
||||
|
||||
it("invalidates only the named settings on its host for a live notification", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const options = settingsQuery(plugin);
|
||||
await plugin.queryClient.fetchQuery(options);
|
||||
const observer = new QueryObserver(plugin.queryClient, options);
|
||||
cleanups.push(observer.subscribe(() => {}));
|
||||
plugin.queryClient.setQueryData(pluginSettingsKey("other-settings"), "unchanged");
|
||||
const other = new CatalogClient();
|
||||
h.registry.installCatalog("other", other.catalog, { client: other, audio });
|
||||
await h.installed("other").queryClient.fetchQuery(settingsQuery(h.installed("other")));
|
||||
|
||||
const beforeNotification = h.registry.getSnapshot();
|
||||
const retainedInstallation = h.installed();
|
||||
h.client.settings.write({ revision: "1", values: { label: "live" } });
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "plugin_settings_changed",
|
||||
pluginId: "reports",
|
||||
settingsId: "preferences",
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "live" },
|
||||
});
|
||||
expect(plugin.queryClient.getQueryState(pluginSettingsKey("other-settings"))?.isInvalidated).toBe(
|
||||
false,
|
||||
);
|
||||
expect(other.settings.reads).toBe(1);
|
||||
expect(h.registry.getSnapshot()).toBe(beforeNotification);
|
||||
expect(h.installed()).toBe(retainedInstallation);
|
||||
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.client.fetches).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores an in-flight catalog snapshot after disconnect", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
await plugin.queryClient.fetchQuery(settingsQuery(plugin));
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
h.stop();
|
||||
let resolveCatalog: (value: Catalog) => void = () => {};
|
||||
const pending = new Promise<Catalog>((resolve) => {
|
||||
resolveCatalog = resolve;
|
||||
});
|
||||
let fetching: () => void = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
fetching = resolve;
|
||||
});
|
||||
class PendingClient extends CatalogClient {
|
||||
override getPluginCatalog() {
|
||||
fetching();
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
const stop = h.sync({ client: new PendingClient(h.client.settings) });
|
||||
await started;
|
||||
stop();
|
||||
resolveCatalog(catalog("late"));
|
||||
await pending;
|
||||
// Drain the catalog continuation through a subsequently queued task.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||
expect(h.client.settings.reads).toBe(1);
|
||||
expect(h.evaluations()).toBe(1);
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { AudioEngine } from "@/audio";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginRegistry } from "./registry";
|
||||
import { pluginSettingsKey, pluginSettingsPrefix } from "./settings/query";
|
||||
|
||||
interface CatalogSyncInput {
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
audio: Pick<AudioEngine, "play"> | null;
|
||||
connected: boolean;
|
||||
supported: boolean;
|
||||
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
|
||||
}
|
||||
|
||||
export function syncPluginCatalog({
|
||||
serverId,
|
||||
client,
|
||||
audio,
|
||||
connected,
|
||||
supported,
|
||||
registry,
|
||||
}: CatalogSyncInput): () => void {
|
||||
if (!supported || !audio) {
|
||||
registry.removeHost(serverId);
|
||||
return () => {};
|
||||
}
|
||||
// A dropped socket reconnects on the same client, so installations keep rendering offline.
|
||||
if (!connected) return () => {};
|
||||
const installationOptions = { client, audio };
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
function refresh(replacePluginId?: string) {
|
||||
refreshQueue = refreshQueue.then(() =>
|
||||
client
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId });
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return refreshQueue;
|
||||
}
|
||||
const observation = client.observeEvents([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
void refresh().then(() => {
|
||||
if (cancelled) return undefined;
|
||||
// Retained replicas miss offline writes and do not refetch when a screen mounts.
|
||||
for (const plugin of registry.getSnapshot()) {
|
||||
if (plugin.serverId !== serverId) continue;
|
||||
void plugin.queryClient.invalidateQueries({
|
||||
queryKey: pluginSettingsPrefix,
|
||||
refetchType: "all",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "status") return;
|
||||
if (message.payload.status === "plugin_settings_changed") {
|
||||
const { pluginId, settingsId } = message.payload;
|
||||
if (typeof settingsId === "string") {
|
||||
const plugin = registry
|
||||
.getSnapshot()
|
||||
.find((item) => item.serverId === serverId && item.id === pluginId);
|
||||
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
||||
}
|
||||
}
|
||||
if (message.payload.status === "plugin_catalog_changed") {
|
||||
const pluginId = message.payload.pluginId;
|
||||
if (typeof pluginId === "string") void refresh(pluginId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { pluginSettingsKey } from "./settings/use-settings";
|
||||
import { useEffect } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { pluginRegistry } from "./registry";
|
||||
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||
|
||||
export function PluginCatalogSync({
|
||||
serverId,
|
||||
@@ -17,18 +17,72 @@ export function PluginCatalogSync({
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "plugins");
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
syncPluginCatalog({
|
||||
serverId,
|
||||
client,
|
||||
audio,
|
||||
connected,
|
||||
supported,
|
||||
registry: pluginRegistry,
|
||||
}),
|
||||
[audio, client, connected, serverId, supported],
|
||||
);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
if (!supported || !audio) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
if (!connected) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
const refresh = (replacePluginId?: string) => {
|
||||
refreshQueue = refreshQueue.then(() =>
|
||||
client
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
audio,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return refreshQueue;
|
||||
};
|
||||
const observation = client.observeEvents([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
void refresh();
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "status") return;
|
||||
if (message.payload.status === "plugin_settings_changed") {
|
||||
const { pluginId, settingsId } = message.payload;
|
||||
if (typeof settingsId === "string") {
|
||||
const plugin = pluginRegistry
|
||||
.getSnapshot()
|
||||
.find((item) => item.serverId === serverId && item.id === pluginId);
|
||||
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
||||
}
|
||||
}
|
||||
if (message.payload.status === "plugin_catalog_changed") {
|
||||
const pluginId = message.payload.pluginId;
|
||||
if (typeof pluginId === "string") void refresh(pluginId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||
};
|
||||
}, [audio, client, connected, serverId, supported]);
|
||||
|
||||
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
||||
return null;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
|
||||
const ensureDirectory = async () => {
|
||||
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
|
||||
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||
};
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
await ensureDirectory();
|
||||
return FileSystem.readDirectoryAsync(directory);
|
||||
},
|
||||
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
|
||||
async writeAtomic(name, contents) {
|
||||
await ensureDirectory();
|
||||
const temporary = `${directory}${randomUUID()}.tmp`;
|
||||
try {
|
||||
await FileSystem.writeAsStringAsync(temporary, contents);
|
||||
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
|
||||
} finally {
|
||||
await FileSystem.deleteAsync(temporary, { idempotent: true });
|
||||
}
|
||||
},
|
||||
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
|
||||
},
|
||||
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
const DATABASE_NAME = "paseo-plugin-client-bundles";
|
||||
const STORE_NAME = "bundles";
|
||||
let opening: Promise<IDBDatabase> | undefined;
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
}).catch((error) => {
|
||||
opening = undefined;
|
||||
throw error;
|
||||
});
|
||||
return opening;
|
||||
}
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const prefix = `${encodeURIComponent(serverId)}/`;
|
||||
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
|
||||
async function run<T>(
|
||||
mode: IDBTransactionMode,
|
||||
operation: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
const database = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const request = operation(transaction.objectStore(STORE_NAME));
|
||||
transaction.addEventListener("complete", () => resolve(request.result));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
}
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
return (await run("readonly", (store) => store.getAllKeys(range())))
|
||||
.filter((key): key is string => typeof key === "string")
|
||||
.map((key) => key.slice(prefix.length));
|
||||
},
|
||||
async read(name) {
|
||||
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
|
||||
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
|
||||
return result;
|
||||
},
|
||||
async writeAtomic(name, contents) {
|
||||
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
|
||||
},
|
||||
async remove(name) {
|
||||
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
|
||||
},
|
||||
},
|
||||
async (contents) => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createHostPluginClientBundleStore } from "./index.web";
|
||||
|
||||
async function sha256(contents: string) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
test("browser bundles survive a fresh store and remain isolated by host", async () => {
|
||||
const first = createHostPluginClientBundleStore("bundle-test-host-a");
|
||||
const second = createHostPluginClientBundleStore("bundle-test-host-b");
|
||||
const clientBundle = "bundle".repeat(250_000);
|
||||
const entry = {
|
||||
id: "same-plugin",
|
||||
clientBundle,
|
||||
clientBundleHash: await sha256(clientBundle),
|
||||
};
|
||||
const otherCode = "other host code";
|
||||
const other = {
|
||||
id: entry.id,
|
||||
clientBundle: otherCode,
|
||||
clientBundleHash: await sha256(otherCode),
|
||||
};
|
||||
try {
|
||||
await first.replace([entry]);
|
||||
await second.replace([other]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
|
||||
await first.replace([]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
|
||||
expect(await second.load()).toEqual([other]);
|
||||
} finally {
|
||||
await first.replace([]);
|
||||
await second.replace([]);
|
||||
}
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, test } from "vitest";
|
||||
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
|
||||
|
||||
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
class BundleFiles implements PluginBundleFiles {
|
||||
readonly contents = new Map<string, string>();
|
||||
readonly writes: string[] = [];
|
||||
async list() {
|
||||
return [...this.contents.keys()];
|
||||
}
|
||||
async read(name: string) {
|
||||
const contents = this.contents.get(name);
|
||||
if (contents === undefined) throw new Error("File disappeared");
|
||||
return contents;
|
||||
}
|
||||
async writeAtomic(name: string, contents: string) {
|
||||
this.writes.push(name);
|
||||
this.contents.set(name, contents);
|
||||
}
|
||||
async remove(name: string) {
|
||||
this.contents.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function bundle(id: string, clientBundle: string) {
|
||||
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
|
||||
}
|
||||
|
||||
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
|
||||
const files = new BundleFiles();
|
||||
const old = await bundle("changed", "old");
|
||||
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
|
||||
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
|
||||
const cold = createPluginClientBundleStore(files, sha256);
|
||||
expect(await cold.load()).toEqual([old, kept]);
|
||||
const changed = await bundle("changed", "new");
|
||||
await cold.replace([kept, changed]);
|
||||
expect(files.contents.size).toBe(2);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
|
||||
expect(files.writes).toHaveLength(3);
|
||||
await cold.replace([]);
|
||||
expect(files.contents.size).toBe(0);
|
||||
});
|
||||
|
||||
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
|
||||
const files = new BundleFiles();
|
||||
const entries = await Promise.all([
|
||||
bundle("same-length", "good"),
|
||||
bundle("truncated", "long"),
|
||||
bundle("missing", "lost"),
|
||||
]);
|
||||
await createPluginClientBundleStore(files, sha256).replace(entries);
|
||||
const names = [...files.contents.keys()];
|
||||
files.contents.set(names[0]!, "evil");
|
||||
files.contents.set(names[1]!, "lo");
|
||||
// The directory entry still exists while reading the file fails.
|
||||
const unreadable: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) =>
|
||||
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
|
||||
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
|
||||
remove: (name) => files.remove(name),
|
||||
};
|
||||
const cold = createPluginClientBundleStore(unreadable, sha256);
|
||||
expect(await cold.load()).toEqual([]);
|
||||
await cold.replace(entries);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
|
||||
});
|
||||
|
||||
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
|
||||
const files = new BundleFiles();
|
||||
const previous = await bundle("plugin", "previous");
|
||||
await createPluginClientBundleStore(files, sha256).replace([previous]);
|
||||
const failing: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) => files.read(name),
|
||||
remove: (name) => files.remove(name),
|
||||
writeAtomic: async () => {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
|
||||
).rejects.toThrow("Disk full");
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
|
||||
});
|
||||
|
||||
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
|
||||
test.each(["valid", "corrupt", "missing"])(
|
||||
"cold catalog uses a %s persisted bundle correctly",
|
||||
async (state) => {
|
||||
const files = new BundleFiles();
|
||||
const entry = await bundle("plugin", "good code");
|
||||
await createPluginClientBundleStore(files, sha256).replace([entry]);
|
||||
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
|
||||
if (state === "missing") files.contents.clear();
|
||||
let open = () => {};
|
||||
let receive: (data: unknown, binary: boolean) => void = () => {};
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "plugin-cache-test",
|
||||
reconnect: { enabled: false },
|
||||
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
|
||||
transportFactory: () => ({
|
||||
send(data) {
|
||||
if (typeof data !== "string") return;
|
||||
const frame = JSON.parse(data);
|
||||
if (frame.type !== "session") return;
|
||||
const request = frame.message;
|
||||
if (request.type !== "plugin.catalog.get.request") return;
|
||||
requests.push(request);
|
||||
const { clientBundle: _code, ...reference } = entry;
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
plugins: [request.cachedClientBundles ? reference : entry],
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
},
|
||||
close() {},
|
||||
onOpen(handler) {
|
||||
open = handler;
|
||||
return () => {};
|
||||
},
|
||||
onMessage(handler) {
|
||||
receive = handler;
|
||||
return () => {};
|
||||
},
|
||||
onClose() {
|
||||
return () => {};
|
||||
},
|
||||
onError() {
|
||||
return () => {};
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const connecting = client.connect();
|
||||
open();
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "cache-host",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { plugins: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
await connecting;
|
||||
expect(await client.getPluginCatalog()).toEqual([entry]);
|
||||
if (state === "valid")
|
||||
expect(requests[0]?.cachedClientBundles).toEqual([
|
||||
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
|
||||
]);
|
||||
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
|
||||
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
import type {
|
||||
PluginClientBundle,
|
||||
PluginClientBundleStore,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
|
||||
export interface PluginBundleFiles {
|
||||
list(): Promise<string[]>;
|
||||
read(name: string): Promise<string>;
|
||||
writeAtomic(name: string, contents: string): Promise<void>;
|
||||
remove(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPluginClientBundleStore(
|
||||
files: PluginBundleFiles,
|
||||
sha256: (contents: string) => Promise<string>,
|
||||
): PluginClientBundleStore {
|
||||
const verified = new Set<string>();
|
||||
const filename = (bundle: PluginClientBundle) =>
|
||||
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
|
||||
return {
|
||||
async load() {
|
||||
const bundles: PluginClientBundle[] = [];
|
||||
for (const name of await files.list()) {
|
||||
try {
|
||||
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
|
||||
if (!match) continue;
|
||||
const id = decodeURIComponent(match[1]!);
|
||||
const clientBundleHash = match[2]!;
|
||||
const clientBundle = await files.read(name);
|
||||
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
|
||||
verified.add(name);
|
||||
bundles.push({ id, clientBundleHash, clientBundle });
|
||||
} catch {
|
||||
// Missing, unreadable or corrupt files are cache misses, never executable code.
|
||||
}
|
||||
}
|
||||
return bundles;
|
||||
},
|
||||
async replace(bundles) {
|
||||
const retained = new Set<string>();
|
||||
for (const bundle of bundles) {
|
||||
const name = filename(bundle);
|
||||
retained.add(name);
|
||||
if (verified.has(name)) continue;
|
||||
await files.writeAtomic(name, bundle.clientBundle);
|
||||
verified.add(name);
|
||||
}
|
||||
for (const name of await files.list()) {
|
||||
if (retained.has(name)) continue;
|
||||
await files.remove(name);
|
||||
verified.delete(name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -219,10 +219,7 @@ test("locale snapshots preserve installation state and observations, then releas
|
||||
expect(after.paseo).toBe(before.paseo);
|
||||
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
|
||||
expect(h.open).toEqual(new Set(["notes"]));
|
||||
const translatedSnapshot = h.plugins.getSnapshot();
|
||||
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
||||
expect(h.plugins.getSnapshot()).toBe(translatedSnapshot);
|
||||
expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN");
|
||||
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
|
||||
h.plugins.removeHost("host");
|
||||
expect(listeners.size).toBe(0);
|
||||
|
||||
@@ -27,12 +27,12 @@ const pluginRegistry = {
|
||||
installCatalog(
|
||||
serverId: string,
|
||||
catalog: Parameters<typeof registry.installCatalog>[1],
|
||||
options: { replacePluginId?: string; client?: DaemonClient } = {},
|
||||
options: { replacePluginId?: string } = {},
|
||||
) {
|
||||
return registry.installCatalog(
|
||||
serverId,
|
||||
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
||||
{ client: daemonClient, ...options, audio },
|
||||
{ ...options, client: daemonClient, audio },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -118,37 +118,6 @@ describe("PluginRegistry", () => {
|
||||
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the snapshot when the same catalog is installed again", () => {
|
||||
const clientBundle = timelineBundle("one");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||
const snapshot = pluginRegistry.getSnapshot();
|
||||
const transformers = snapshot[0]?.timelineTransformers;
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = pluginRegistry.subscribe(listener);
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||
unsubscribe();
|
||||
|
||||
expect(pluginRegistry.getSnapshot()).toBe(snapshot);
|
||||
expect(pluginRegistry.getSnapshot()[0]?.timelineTransformers).toBe(transformers);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinstalls an unchanged bundle for a new host client", () => {
|
||||
const clientBundle = bundle("one");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
|
||||
client: {} as DaemonClient,
|
||||
});
|
||||
|
||||
const second = pluginRegistry.getSnapshot()[0];
|
||||
expect(second).not.toBe(first);
|
||||
expect(first?.lifetime.signal.aborted).toBe(true);
|
||||
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces the query cache when the plugin bundle changes", () => {
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
|
||||
@@ -19,8 +19,6 @@ export class PluginRegistry {
|
||||
private snapshot: InstalledPlugin[] = [];
|
||||
private readonly disposed = new WeakSet<InstalledPlugin>();
|
||||
private readonly evaluationErrors = new Map<string, string>();
|
||||
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
|
||||
private readonly clientByHost = new Map<string, DaemonClient>();
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: {
|
||||
@@ -51,15 +49,10 @@ export class PluginRegistry {
|
||||
},
|
||||
): boolean {
|
||||
const previous = this.byHost.get(serverId) ?? [];
|
||||
const previousErrors = this.hostEvaluationErrors(serverId);
|
||||
// Installations call the client they were created with, so a new client replaces them all.
|
||||
const sameClient = this.clientByHost.get(serverId) === options.client;
|
||||
this.clientByHost.set(serverId, options.client);
|
||||
const previousTimelineBundles = previous
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
const preserved = catalog.flatMap((entry) => {
|
||||
if (!sameClient) return [];
|
||||
const existing = previous.find(
|
||||
(plugin) =>
|
||||
plugin.id !== options.replacePluginId &&
|
||||
@@ -153,13 +146,13 @@ export class PluginRegistry {
|
||||
}
|
||||
}
|
||||
this.byHost.set(serverId, installed);
|
||||
// An unchanged catalog keeps the snapshot, so timeline projections cached against the
|
||||
// installed transformers stay valid across a reconnect.
|
||||
const unchanged =
|
||||
installed.length === previous.length &&
|
||||
installed.every((plugin, index) => plugin === previous[index]) &&
|
||||
this.hostEvaluationErrors(serverId) === previousErrors;
|
||||
if (!unchanged) this.publish();
|
||||
if (
|
||||
installed.length !== catalog.length ||
|
||||
installed.length !== previous.length ||
|
||||
installed.some((plugin, index) => plugin !== previous[index])
|
||||
) {
|
||||
this.publish();
|
||||
}
|
||||
const installedTimelineBundles = installed
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
@@ -177,17 +170,9 @@ export class PluginRegistry {
|
||||
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
|
||||
}
|
||||
this.byHost.delete(serverId);
|
||||
this.clientByHost.delete(serverId);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private hostEvaluationErrors(serverId: string): string {
|
||||
const prefix = `${serverId}/`;
|
||||
return JSON.stringify(
|
||||
[...this.evaluationErrors].filter(([key]) => key.startsWith(prefix)).sort(),
|
||||
);
|
||||
}
|
||||
|
||||
private dispose(plugin: InstalledPlugin): void {
|
||||
if (this.disposed.has(plugin)) return;
|
||||
this.disposed.add(plugin);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const pluginSettingsPrefix = ["plugin-settings"] as const;
|
||||
export const pluginSettingsKey = (id: string) => [...pluginSettingsPrefix, id] as const;
|
||||
@@ -6,8 +6,8 @@ import { type SettingsDefinition } from "@getpaseo/plugin";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { z, type ZodType } from "zod";
|
||||
import { pluginSettingsKey } from "./query";
|
||||
|
||||
export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const;
|
||||
function message(error: unknown): string {
|
||||
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
@@ -590,7 +589,6 @@ function createDefaultDeps(
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
providerSnapshots: "wire",
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
@@ -670,7 +668,6 @@ function createDefaultDeps(
|
||||
),
|
||||
reconnect: { enabled: false },
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -687,7 +684,6 @@ function createDefaultDeps(
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
},
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
|
||||
@@ -12,69 +12,6 @@ test("settings restart completes when a replacement worker is observed without a
|
||||
expect(pid).toBe(11);
|
||||
});
|
||||
|
||||
test("settings restart keeps terminals by default and closes them only when asked", async () => {
|
||||
let pid = 10;
|
||||
const requests: Array<{ killTerminals: boolean }> = [];
|
||||
const deps = {
|
||||
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
|
||||
restartServer: async (_reason: string, options: { killTerminals: boolean }) => {
|
||||
requests.push(options);
|
||||
pid += 1;
|
||||
},
|
||||
};
|
||||
await restartDaemonFromSettings("daemon", "settings", deps);
|
||||
await restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true });
|
||||
expect(requests).toEqual([{ killTerminals: false }, { killTerminals: true }]);
|
||||
});
|
||||
|
||||
test("a close-terminals restart completes when the replacement reports every terminal closed", async () => {
|
||||
let pid = 10;
|
||||
await restartDaemonFromSettings(
|
||||
"daemon",
|
||||
"settings",
|
||||
{
|
||||
getStatus: async () => ({
|
||||
pid,
|
||||
version: "1.0.0",
|
||||
serverId: "daemon",
|
||||
terminalCleanupUnconfirmed: [],
|
||||
}),
|
||||
restartServer: async () => {
|
||||
pid = 11;
|
||||
},
|
||||
},
|
||||
{ killTerminals: true },
|
||||
);
|
||||
expect(pid).toBe(11);
|
||||
});
|
||||
|
||||
test("a close-terminals restart fails with the processes the replacement could not confirm closed", async () => {
|
||||
let pid = 10;
|
||||
const leftover = {
|
||||
pid: 4242,
|
||||
role: "terminal program",
|
||||
reason: "still alive",
|
||||
detail: "survived SIGKILL",
|
||||
};
|
||||
const deps = {
|
||||
getStatus: async () => ({
|
||||
pid,
|
||||
version: "1.0.0",
|
||||
serverId: "daemon",
|
||||
terminalCleanupUnconfirmed: pid === 10 ? [] : [leftover],
|
||||
}),
|
||||
restartServer: async () => {
|
||||
pid += 1;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true }),
|
||||
).rejects.toThrow("pid 4242 (terminal program): still alive (survived SIGKILL)");
|
||||
// Keeping terminals asks nothing of the cleanup, so a leftover does not fail it.
|
||||
await restartDaemonFromSettings("daemon", "settings", deps);
|
||||
});
|
||||
|
||||
test("a status permission failure prevents the restart request", async () => {
|
||||
let restarted = false;
|
||||
await expect(
|
||||
|
||||
@@ -1,61 +1,35 @@
|
||||
interface UnconfirmedTerminalProcess {
|
||||
pid: number;
|
||||
role: string;
|
||||
reason: string;
|
||||
detail: string;
|
||||
}
|
||||
interface WorkerStatus {
|
||||
pid: number;
|
||||
serverId: string;
|
||||
version: string | null;
|
||||
// Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup to report.
|
||||
terminalCleanupUnconfirmed?: UnconfirmedTerminalProcess[];
|
||||
}
|
||||
interface StatusReader {
|
||||
getStatus: () => Promise<WorkerStatus>;
|
||||
}
|
||||
|
||||
export interface SettingsDaemonRestartDeps extends StatusReader {
|
||||
restartServer: (reason: string, options: { killTerminals: boolean }) => Promise<unknown>;
|
||||
restartServer: (reason: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledgment starts the wait; only a different ready worker completes it. Terminals survive
|
||||
* the restart unless `killTerminals` is set (daemons advertising terminalsSurviveRestart); then
|
||||
* the replacement must also report every terminal process gone.
|
||||
*/
|
||||
/** Acknowledgment starts the wait; only a different ready worker completes it. */
|
||||
export async function restartDaemonFromSettings(
|
||||
hostServerId: string,
|
||||
reason: string,
|
||||
deps: SettingsDaemonRestartDeps,
|
||||
options: { killTerminals: boolean } = { killTerminals: false },
|
||||
): Promise<void> {
|
||||
const previous = await readSelectedWorker(hostServerId, deps);
|
||||
let acknowledged = false;
|
||||
try {
|
||||
await deps.restartServer(reason, options);
|
||||
await deps.restartServer(reason);
|
||||
acknowledged = true;
|
||||
} catch (error) {
|
||||
if (!isReconnectFailure(error)) throw error;
|
||||
}
|
||||
let replacement: WorkerStatus;
|
||||
try {
|
||||
replacement = await observeReplacement(previous, deps);
|
||||
await observeReplacement(previous, deps);
|
||||
} catch (error) {
|
||||
throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error });
|
||||
}
|
||||
const unconfirmed = replacement.terminalCleanupUnconfirmed ?? [];
|
||||
if (options.killTerminals && unconfirmed.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
"The daemon restarted, but closing its terminals was not confirmed; these processes may still be running:",
|
||||
...unconfirmed.map(
|
||||
(entry) => `pid ${entry.pid} (${entry.role}): ${entry.reason} (${entry.detail})`,
|
||||
),
|
||||
"Stopping the daemon retries the cleanup.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDaemonFromSettings(
|
||||
|
||||
@@ -65,7 +65,6 @@ import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
import { RestartRecoveryCard } from "./restart-recovery-card";
|
||||
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
|
||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||
|
||||
@@ -293,7 +292,6 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<BrowserToolsOptInCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
<RestartRecoveryCard serverId={serverId} />
|
||||
<IdleRuntimeCleanupCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
@@ -567,15 +565,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const runtime = getHostRuntimeStore();
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
// Rendered in the card, not an Alert.alert: react-native-web's Alert is a no-op, and a failed
|
||||
// close-terminals restart must name the processes left running until the user retries.
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
|
||||
const terminalsSurviveRestart = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[host.serverId]?.serverInfo?.features?.terminalsSurviveRestart === true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -588,80 +578,71 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
|
||||
const waitForDaemonRestart = useCallback(async (request: Promise<void>) => {
|
||||
try {
|
||||
await request;
|
||||
} catch (error) {
|
||||
console.error("[HostPage] Daemon restart failed", error);
|
||||
if (isMountedRef.current) {
|
||||
setRestartError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsRestarting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startRestart = useCallback(
|
||||
(killTerminals: boolean) => {
|
||||
if (!daemonClient) {
|
||||
const waitForDaemonRestart = useCallback(
|
||||
async (request: Promise<void>) => {
|
||||
try {
|
||||
await request;
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.unavailableTitle"),
|
||||
t("settings.host.daemon.restart.unavailableMessage"),
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsRestarting(false);
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.offlineTitle"),
|
||||
t("settings.host.daemon.restart.offlineMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
|
||||
message: killTerminals
|
||||
? t("settings.host.daemon.restart.closeTerminalsConfirmMessage")
|
||||
: t("settings.host.daemon.restart.confirmMessage"),
|
||||
confirmLabel: killTerminals
|
||||
? t("settings.host.daemon.restart.closeTerminals")
|
||||
: t("settings.host.daemon.restart.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
setRestartError(null);
|
||||
setIsRestarting(true);
|
||||
const restartRequest = restartDaemonFromSettings(
|
||||
host.serverId,
|
||||
`settings_daemon_restart_${host.serverId}`,
|
||||
{
|
||||
restartServer: (reason, options) =>
|
||||
daemonClient.restartServer(reason, undefined, options),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
},
|
||||
{ killTerminals },
|
||||
);
|
||||
void waitForDaemonRestart(restartRequest);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
|
||||
if (isMountedRef.current) {
|
||||
setRestartError(t("settings.host.daemon.restart.dialogFailedMessage"));
|
||||
}
|
||||
});
|
||||
},
|
||||
[daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRestart = useCallback(() => startRestart(false), [startRestart]);
|
||||
const handleRestartAndCloseTerminals = useCallback(() => startRestart(true), [startRestart]);
|
||||
const handleRestart = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.unavailableTitle"),
|
||||
t("settings.host.daemon.restart.unavailableMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.offlineTitle"),
|
||||
t("settings.host.daemon.restart.offlineMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
|
||||
message: t("settings.host.daemon.restart.confirmMessage"),
|
||||
confirmLabel: t("settings.host.daemon.restart.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
setIsRestarting(true);
|
||||
const restartRequest = restartDaemonFromSettings(
|
||||
host.serverId,
|
||||
`settings_daemon_restart_${host.serverId}`,
|
||||
{
|
||||
restartServer: (reason) => daemonClient.restartServer(reason),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
void waitForDaemonRestart(restartRequest);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
t("settings.host.daemon.restart.dialogFailedMessage"),
|
||||
);
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-restart-card">
|
||||
@@ -683,35 +664,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
: t("settings.host.daemon.restart.confirm")}
|
||||
</Button>
|
||||
</View>
|
||||
{terminalsSurviveRestart ? (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.host.daemon.restart.closeTerminalsHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleRestartAndCloseTerminals}
|
||||
disabled={isRestarting || !daemonClient || !isConnected}
|
||||
testID="host-page-restart-close-terminals-button"
|
||||
>
|
||||
{t("settings.host.daemon.restart.closeTerminals")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{restartError ? (
|
||||
<View style={styles.cardAlert}>
|
||||
<InlineAlert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.host.daemon.restart.requestFailedTitle")}
|
||||
description={restartError}
|
||||
testID="host-page-restart-error"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -896,7 +848,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
/>
|
||||
) : null}
|
||||
{updateState.status === "failed" ? (
|
||||
<View style={styles.cardAlert}>
|
||||
<View style={styles.updateFailure}>
|
||||
<InlineAlert
|
||||
size="sm"
|
||||
variant="error"
|
||||
@@ -1750,7 +1702,7 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
cardAlert: {
|
||||
updateFailure: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
const DEFAULT_RESUME_PROMPT = "Resume working.";
|
||||
|
||||
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
|
||||
export function RestartRecoveryCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useHostFeature(serverId, "resumeOnStartupSetting");
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: { resumeOnStartup?: boolean; resumePrompt?: string }) => {
|
||||
const result = await patchConfig(next);
|
||||
if (!result) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(next: boolean) => {
|
||||
mutation.mutate({ resumeOnStartup: next });
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
mutation.reset();
|
||||
setIsEditing(true);
|
||||
}, [mutation]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (mutation.isPending) return;
|
||||
mutation.reset();
|
||||
setIsEditing(false);
|
||||
}, [mutation]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(prompt: string) => {
|
||||
mutation.mutate({ resumePrompt: prompt }, { onSuccess: () => setIsEditing(false) });
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
if (!isConnected || !isSupported || !config) return null;
|
||||
|
||||
const isEnabled = config.resumeOnStartup !== false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.card} testID="host-page-restart-recovery-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.host.agents.resumeOnStartup.title")}
|
||||
</Text>
|
||||
<SettingsInfoTip
|
||||
title={t("settings.host.agents.resumeOnStartup.title")}
|
||||
info={t("settings.host.agents.resumeOnStartup.info")}
|
||||
/>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.host.agents.resumeOnStartup.hint")}
|
||||
</Text>
|
||||
{mutation.error && !isEditing ? (
|
||||
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
|
||||
{mutation.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.trailing}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleOpen}
|
||||
disabled={!isEnabled || mutation.isPending}
|
||||
accessibilityLabel={t("settings.host.agents.resumeOnStartup.editAccessibilityLabel")}
|
||||
testID="host-page-restart-recovery-edit"
|
||||
>
|
||||
{t("settings.host.agents.resumeOnStartup.edit")}
|
||||
</Button>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={mutation.isPending}
|
||||
accessibilityLabel={t("settings.host.agents.resumeOnStartup.title")}
|
||||
testID="host-page-restart-recovery-switch"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isEditing ? (
|
||||
<ResumePromptSheet
|
||||
initialPrompt={config.resumePrompt ?? DEFAULT_RESUME_PROMPT}
|
||||
isSaving={mutation.isPending}
|
||||
error={mutation.error?.message ?? null}
|
||||
onSave={handleSave}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumePromptSheet({
|
||||
initialPrompt,
|
||||
isSaving,
|
||||
error,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initialPrompt: string;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
onSave: (prompt: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState(initialPrompt);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.host.agents.resumeOnStartup.prompt") }),
|
||||
[t],
|
||||
);
|
||||
const canSave = draft !== initialPrompt && draft.trim().length > 0 && !isSaving;
|
||||
|
||||
const handleSave = useCallback(() => onSave(draft), [draft, onSave]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible
|
||||
onClose={onClose}
|
||||
testID="host-page-restart-recovery-sheet"
|
||||
desktopMaxWidth={560}
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="host-page-restart-recovery-prompt-input"
|
||||
accessibilityLabel={t("settings.host.agents.resumeOnStartup.prompt")}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder={DEFAULT_RESUME_PROMPT}
|
||||
/>
|
||||
{error ? (
|
||||
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.sheetActions}>
|
||||
<Button variant="ghost" size="sm" onPress={onClose} disabled={isSaving}>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleSave}
|
||||
disabled={!canSave}
|
||||
testID="host-page-restart-recovery-save"
|
||||
>
|
||||
{t("common.actions.save")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
sheetActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
useWorkspaceSetupStore,
|
||||
} from "@/stores/workspace-setup-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
@@ -880,6 +881,13 @@ function shouldShowWorkspaceScreenHeader(input: {
|
||||
return !input.isFocusModeEnabled || input.isMobile;
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane the user acted inside owns the tab: it opens there, and an existing tab
|
||||
* moves there. No pane means the open has no opinion beyond the focused pane.
|
||||
@@ -1058,6 +1066,14 @@ function WorkspaceScreenContent({
|
||||
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
|
||||
useWorkspaceRouteActions(normalizedServerId);
|
||||
|
||||
const workspaceTerminalScopeKey = useMemo(
|
||||
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
useWorkspaceTerminalSessionRetention({
|
||||
scopeKey: workspaceTerminalScopeKey,
|
||||
});
|
||||
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const supportsProvidersSnapshot = useSessionStore(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
releaseWorkspaceTerminalSession,
|
||||
retainWorkspaceTerminalSession,
|
||||
} from "@/terminal/runtime/workspace-terminal-session";
|
||||
|
||||
export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void {
|
||||
useEffect(() => {
|
||||
if (!input.scopeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
retainWorkspaceTerminalSession({ scopeKey: input.scopeKey });
|
||||
return () => {
|
||||
releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! });
|
||||
};
|
||||
}, [input.scopeKey]);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
NATIVE_TERMINAL_PARSE_CHUNK_CHARS,
|
||||
} from "./headless-terminal-output-drain";
|
||||
import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model";
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot";
|
||||
|
||||
const ROWS = 12;
|
||||
const COLS = 80;
|
||||
@@ -95,7 +94,6 @@ function createScreenRestorer(
|
||||
const frameRecorder = createFrameRecorder();
|
||||
let screen: TerminalScreenState | null = null;
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
restoreSize: () => {},
|
||||
write: (chunk) => terminal.write(chunk),
|
||||
reset: () => terminal.reset(),
|
||||
getViewportState: () => {
|
||||
@@ -109,12 +107,7 @@ function createScreenRestorer(
|
||||
|
||||
return {
|
||||
async restore(snapshot: Awaited<ReturnType<typeof benchmarkSnapshot>>) {
|
||||
drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols });
|
||||
drain.replay({
|
||||
type: "data",
|
||||
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(snapshot)),
|
||||
});
|
||||
drain.replay({ type: "end" });
|
||||
drain.restoreSnapshot(snapshot);
|
||||
await drain.flush();
|
||||
frameRecorder.frames[0]?.();
|
||||
return screen;
|
||||
@@ -129,7 +122,6 @@ describe("native terminal output drain", () => {
|
||||
const frameRecorder = createFrameRecorder();
|
||||
const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2);
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
restoreSize: () => {},
|
||||
write: async (chunk) => {
|
||||
writes.push(chunk);
|
||||
},
|
||||
@@ -178,7 +170,6 @@ describe("native terminal output drain", () => {
|
||||
const paintedStates: TerminalViewportState[] = [];
|
||||
const frameRecorder = createFrameRecorder();
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
restoreSize: () => {},
|
||||
write: (chunk) => terminal.write(chunk),
|
||||
reset: () => terminal.reset(),
|
||||
getViewportState: () => terminal.getViewportState(),
|
||||
@@ -211,7 +202,6 @@ describe("native terminal output drain", () => {
|
||||
const scheduledWork: Array<() => void> = [];
|
||||
const frameRecorder = createFrameRecorder();
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
restoreSize: () => {},
|
||||
write: (chunk) => terminal.write(chunk),
|
||||
reset: () => terminal.reset(),
|
||||
getViewportState: () => terminal.getViewportState(),
|
||||
@@ -229,12 +219,7 @@ describe("native terminal output drain", () => {
|
||||
|
||||
await terminal.write("old-one\r\nold-two\r\nold-three\r\n");
|
||||
drain.enqueueText("stale-queued\r\n");
|
||||
drain.replay({ type: "begin" });
|
||||
drain.replay({
|
||||
type: "data",
|
||||
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(restoredSnapshot)),
|
||||
});
|
||||
drain.replay({ type: "end" });
|
||||
drain.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot));
|
||||
await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() });
|
||||
|
||||
const state = terminal.getViewportState();
|
||||
@@ -283,238 +268,3 @@ describe("native terminal output drain", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("replays split Unicode and ANSI with one reset then live output", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 });
|
||||
let resets = 0;
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
restoreSize: () => {},
|
||||
write: (text) => terminal.write(text),
|
||||
reset: (size) => {
|
||||
resets++;
|
||||
if (size) terminal.resize(size);
|
||||
terminal.reset();
|
||||
},
|
||||
getViewportState: () => terminal.getViewportState(),
|
||||
onPaint: () => {},
|
||||
});
|
||||
try {
|
||||
drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 });
|
||||
const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n");
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
drain.replay({ type: "data", data: bytes.subarray(i, i + 1) });
|
||||
drain.replay({ type: "end" });
|
||||
drain.enqueueText("live");
|
||||
await drain.flush();
|
||||
expect(resets).toBe(1);
|
||||
expect(
|
||||
terminal
|
||||
.getBufferWindow({ startRow: 0, rowCount: 4 })
|
||||
.rows.map((row) =>
|
||||
row
|
||||
.filter((cell) => cell.width !== 0)
|
||||
.map((cell) => cell.char)
|
||||
.join(""),
|
||||
)
|
||||
.join("\n"),
|
||||
).toContain("界🙂 replay");
|
||||
expect(
|
||||
terminal
|
||||
.getBufferWindow({ startRow: 0, rowCount: 4 })
|
||||
.rows.map((row) =>
|
||||
row
|
||||
.filter((cell) => cell.width !== 0)
|
||||
.map((cell) => cell.char)
|
||||
.join(""),
|
||||
)
|
||||
.join("\n"),
|
||||
).toContain("live");
|
||||
} finally {
|
||||
drain.dispose();
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("an empty replacement replay resets after an in-flight write finishes", async () => {
|
||||
let finish!: () => void;
|
||||
let started!: () => void;
|
||||
const writing = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let resets = 0;
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
restoreSize: () => {},
|
||||
write: async () => {
|
||||
started();
|
||||
await blocked;
|
||||
},
|
||||
reset: () => {
|
||||
resets++;
|
||||
},
|
||||
getViewportState: () => null,
|
||||
onPaint: () => {},
|
||||
});
|
||||
try {
|
||||
drain.enqueueText("old terminal");
|
||||
await writing;
|
||||
drain.replay({ type: "begin" });
|
||||
drain.replay({ type: "end" });
|
||||
finish();
|
||||
await drain.flush();
|
||||
expect(resets).toBe(1);
|
||||
expect(drain.getQueuedChars()).toBe(0);
|
||||
} finally {
|
||||
finish();
|
||||
drain.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("replay geometry returns to measured layout before live writes and unchanged-layout focus", async () => {
|
||||
const measured = { rows: 24, cols: 40 };
|
||||
const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 1000 });
|
||||
const policy = createTerminalResizePolicy(measured);
|
||||
const sizes: number[] = [];
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
write: async (text) => {
|
||||
sizes.push(terminal.getState().cols);
|
||||
await terminal.write(text);
|
||||
},
|
||||
reset: (size) => {
|
||||
if (size) terminal.resize(size);
|
||||
terminal.reset();
|
||||
},
|
||||
restoreSize: () => terminal.resize(measured),
|
||||
getViewportState: () => terminal.getViewportState(),
|
||||
onPaint: () => {},
|
||||
});
|
||||
try {
|
||||
drain.replay({ type: "begin", rows: 30, cols: 120 });
|
||||
drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") });
|
||||
drain.replay({ type: "end" });
|
||||
drain.enqueueText("LIVE-" + "y".repeat(60));
|
||||
await drain.flush();
|
||||
expect(sizes).toEqual([120, 40]);
|
||||
const focus = updateTerminalResizePolicy(policy, {
|
||||
source: "claim",
|
||||
size: measured,
|
||||
claimToken: 1,
|
||||
});
|
||||
expect(focus.measuredSizeChanged).toBe(false);
|
||||
expect(focus.resizeClaim).toMatchObject(measured);
|
||||
expect(terminal.getState()).toMatchObject(measured);
|
||||
expect(terminal.getBufferBounds().cols).toBe(40);
|
||||
const lines = terminal
|
||||
.getState()
|
||||
.grid.map((row) =>
|
||||
row
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
.trimEnd(),
|
||||
)
|
||||
.join("\n");
|
||||
expect(lines).toContain("LIVE-" + "y".repeat(35) + "\n" + "y".repeat(25));
|
||||
} finally {
|
||||
drain.dispose();
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["clear", "replacement", "dispose"] as const)(
|
||||
"cancels queued replay geometry on %s",
|
||||
async (action) => {
|
||||
const local = { rows: 24, cols: 40 };
|
||||
const terminal = createNativeHeadlessTerminal({ ...local, scrollbackLines: 1000 });
|
||||
let finish!: () => void;
|
||||
const block = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const writing = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let first = true;
|
||||
let restores = 0;
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
restoreSize: () => {
|
||||
restores++;
|
||||
terminal.resize(local);
|
||||
},
|
||||
reset: (size) => {
|
||||
if (size) terminal.resize(size);
|
||||
terminal.reset();
|
||||
},
|
||||
write: async (text) => {
|
||||
await terminal.write(text);
|
||||
if (first) {
|
||||
first = false;
|
||||
started();
|
||||
await block;
|
||||
}
|
||||
},
|
||||
getViewportState: () => terminal.getViewportState(),
|
||||
onPaint: () => {},
|
||||
});
|
||||
try {
|
||||
drain.replay({ type: "begin", rows: 30, cols: 120 });
|
||||
drain.replay({ type: "data", data: new TextEncoder().encode("old") });
|
||||
await writing;
|
||||
local.cols = 60;
|
||||
drain.resize();
|
||||
expect(terminal.getState().cols).toBe(120);
|
||||
drain.replay({ type: "end" });
|
||||
if (action === "clear") drain.clear();
|
||||
else if (action === "dispose") {
|
||||
drain.dispose();
|
||||
drain.clear();
|
||||
} else {
|
||||
drain.replay({ type: "begin", rows: 20, cols: 80 });
|
||||
drain.replay({ type: "data", data: new TextEncoder().encode("new") });
|
||||
drain.replay({ type: "end" });
|
||||
}
|
||||
finish();
|
||||
await drain.flush();
|
||||
expect(restores).toBe(action === "dispose" ? 0 : 1);
|
||||
if (action !== "dispose") expect(terminal.getState()).toMatchObject(local);
|
||||
} finally {
|
||||
finish();
|
||||
drain.dispose();
|
||||
terminal.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("holds captured geometry across mid-replay measurement changes", async () => {
|
||||
const source = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
|
||||
await source.write("ABCDEFGH\r\n12345678\r\nabcdefgh\r\nxyz\x1b[2;8H");
|
||||
const ansi = new TextEncoder().encode(renderTerminalSnapshotToAnsi(source.getState()));
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
|
||||
const expected = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
|
||||
await expected.write(new TextDecoder().decode(ansi));
|
||||
expected.resize({ rows: 4, cols: 4 });
|
||||
let measured = { rows: 4, cols: 8 };
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
write: (chunk) => terminal.write(chunk),
|
||||
reset: (size) => {
|
||||
if (size) terminal.resize(size);
|
||||
terminal.reset();
|
||||
},
|
||||
restoreSize: () => terminal.resize(measured),
|
||||
getViewportState: () => terminal.getViewportState(),
|
||||
onPaint: () => {},
|
||||
});
|
||||
try {
|
||||
drain.replay({ type: "begin", rows: 4, cols: 8 });
|
||||
await drain.flush();
|
||||
measured = { rows: 4, cols: 4 };
|
||||
drain.resize();
|
||||
drain.replay({ type: "data", data: ansi });
|
||||
drain.replay({ type: "end" });
|
||||
await drain.flush();
|
||||
expect(terminal.getState()).toEqual(expected.getState());
|
||||
} finally {
|
||||
drain.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot";
|
||||
import type { TerminalViewportState } from "./headless-terminal-state";
|
||||
import { nativeTerminalPerformanceNow } from "./terminal-performance";
|
||||
|
||||
@@ -12,9 +14,9 @@ export interface NativeTerminalParseSample {
|
||||
}
|
||||
|
||||
export interface NativeTerminalOutputDrain {
|
||||
replay(event: import("@getpaseo/client").TerminalReplayEvent): void;
|
||||
enqueueText(text: string): void;
|
||||
resize(): void;
|
||||
restoreText(text: string): void;
|
||||
restoreSnapshot(snapshot: TerminalState): void;
|
||||
clear(): void;
|
||||
flush(): Promise<void>;
|
||||
dispose(): void;
|
||||
@@ -23,8 +25,7 @@ export interface NativeTerminalOutputDrain {
|
||||
|
||||
export interface NativeTerminalOutputDrainOptions {
|
||||
write: (chunk: string) => Promise<void>;
|
||||
restoreSize: () => void;
|
||||
reset: (size?: { rows: number; cols: number }) => void;
|
||||
reset: () => void;
|
||||
getViewportState: () => TerminalViewportState | null;
|
||||
onPaint: (state: TerminalViewportState) => void;
|
||||
onParse?: (sample: NativeTerminalParseSample) => void;
|
||||
@@ -67,22 +68,16 @@ export function createNativeTerminalOutputDrain(
|
||||
const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS;
|
||||
const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN;
|
||||
const idleWaiters: Array<() => void> = [];
|
||||
type Operation =
|
||||
| { type: "write"; text: string }
|
||||
| { type: "reset"; size?: { rows: number; cols: number } }
|
||||
| { type: "restore-size" };
|
||||
let operations: Operation[] = [];
|
||||
let queuedChars = 0;
|
||||
let pendingText = "";
|
||||
let drainScheduled = false;
|
||||
let draining = false;
|
||||
let disposed = false;
|
||||
let replaying = false;
|
||||
let paintFrame: number | null = null;
|
||||
let generation = 0;
|
||||
const replayDecoder = new TextDecoder();
|
||||
let resetBeforeNextWrite = false;
|
||||
|
||||
function resolveIdleIfReady(): void {
|
||||
if (drainScheduled || draining || operations.length > 0) {
|
||||
if (drainScheduled || draining || pendingText.length > 0) {
|
||||
return;
|
||||
}
|
||||
while (idleWaiters.length > 0) {
|
||||
@@ -124,6 +119,15 @@ export function createNativeTerminalOutputDrain(
|
||||
});
|
||||
}
|
||||
|
||||
function replacePendingText(text: string): void {
|
||||
generation += 1;
|
||||
pendingText = text;
|
||||
resetBeforeNextWrite = true;
|
||||
cancelPendingPaint();
|
||||
scheduleDrain();
|
||||
resolveIdleIfReady();
|
||||
}
|
||||
|
||||
async function drainPending(): Promise<void> {
|
||||
if (disposed || draining) {
|
||||
return;
|
||||
@@ -134,23 +138,16 @@ export function createNativeTerminalOutputDrain(
|
||||
let parsedChunks = 0;
|
||||
|
||||
try {
|
||||
while (operations.length > 0 && parsedChunks < chunksPerTurn) {
|
||||
if (disposed || activeGeneration !== generation) return;
|
||||
const operation = operations[0]!;
|
||||
if (operation.type !== "write") {
|
||||
operations.shift();
|
||||
if (operation.type === "reset") options.reset(operation.size);
|
||||
else {
|
||||
options.restoreSize();
|
||||
replaying = false;
|
||||
}
|
||||
requestPaint();
|
||||
continue;
|
||||
while (pendingText.length > 0 && parsedChunks < chunksPerTurn) {
|
||||
if (disposed || activeGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
const chunk = operation.text.slice(0, chunkChars);
|
||||
operation.text = operation.text.slice(chunk.length);
|
||||
queuedChars -= chunk.length;
|
||||
if (!operation.text) operations.shift();
|
||||
if (resetBeforeNextWrite) {
|
||||
options.reset();
|
||||
resetBeforeNextWrite = false;
|
||||
}
|
||||
const chunk = pendingText.slice(0, chunkChars);
|
||||
pendingText = pendingText.slice(chunk.length);
|
||||
const parseStart = nativeTerminalPerformanceNow();
|
||||
await options.write(chunk);
|
||||
if (disposed || activeGeneration !== generation) {
|
||||
@@ -159,14 +156,14 @@ export function createNativeTerminalOutputDrain(
|
||||
options.onParse?.({
|
||||
durationMs: nativeTerminalPerformanceNow() - parseStart,
|
||||
parsedChars: chunk.length,
|
||||
queuedChars,
|
||||
queuedChars: pendingText.length,
|
||||
});
|
||||
requestPaint();
|
||||
parsedChunks += 1;
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
if (!disposed && operations.length > 0) {
|
||||
if (!disposed && pendingText.length > 0) {
|
||||
scheduleDrain();
|
||||
}
|
||||
resolveIdleIfReady();
|
||||
@@ -174,56 +171,38 @@ export function createNativeTerminalOutputDrain(
|
||||
}
|
||||
|
||||
return {
|
||||
replay(event): void {
|
||||
if (disposed) return;
|
||||
if (event.type === "begin") {
|
||||
replaying = true;
|
||||
generation++;
|
||||
replayDecoder.decode();
|
||||
operations = [
|
||||
{
|
||||
type: "reset",
|
||||
size: event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined,
|
||||
},
|
||||
];
|
||||
queuedChars = 0;
|
||||
cancelPendingPaint();
|
||||
} else if (event.type === "data") {
|
||||
this.enqueueText(replayDecoder.decode(event.data, { stream: true }));
|
||||
} else {
|
||||
this.enqueueText(replayDecoder.decode());
|
||||
operations.push({ type: "restore-size" });
|
||||
}
|
||||
scheduleDrain();
|
||||
},
|
||||
enqueueText(text: string): void {
|
||||
if (disposed || !text) return;
|
||||
const last = operations.at(-1);
|
||||
if (last?.type === "write") last.text += text;
|
||||
else operations.push({ type: "write", text });
|
||||
queuedChars += text.length;
|
||||
if (disposed || text.length === 0) {
|
||||
return;
|
||||
}
|
||||
pendingText += text;
|
||||
scheduleDrain();
|
||||
},
|
||||
|
||||
resize(): void {
|
||||
if (disposed || replaying) return;
|
||||
operations.push({ type: "restore-size" });
|
||||
scheduleDrain();
|
||||
restoreText(text: string): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
replacePendingText(text);
|
||||
},
|
||||
|
||||
restoreSnapshot(snapshot: TerminalState): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
replacePendingText(renderTerminalSnapshotToAnsi(snapshot));
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
if (disposed) return;
|
||||
generation += 1;
|
||||
operations = [{ type: "restore-size" }, { type: "reset" }];
|
||||
queuedChars = 0;
|
||||
replayDecoder.decode();
|
||||
pendingText = "";
|
||||
resetBeforeNextWrite = false;
|
||||
cancelPendingPaint();
|
||||
scheduleDrain();
|
||||
resolveIdleIfReady();
|
||||
},
|
||||
|
||||
flush(): Promise<void> {
|
||||
if (!drainScheduled && !draining && operations.length === 0) {
|
||||
if (!drainScheduled && !draining && pendingText.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
scheduleDrain();
|
||||
@@ -235,8 +214,8 @@ export function createNativeTerminalOutputDrain(
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
operations = [];
|
||||
queuedChars = 0;
|
||||
pendingText = "";
|
||||
resetBeforeNextWrite = false;
|
||||
cancelPendingPaint();
|
||||
while (idleWaiters.length > 0) {
|
||||
idleWaiters.shift()?.();
|
||||
@@ -244,7 +223,7 @@ export function createNativeTerminalOutputDrain(
|
||||
},
|
||||
|
||||
getQueuedChars(): number {
|
||||
return queuedChars;
|
||||
return pendingText.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,6 +166,7 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: input.scrollback ?? 10_000,
|
||||
theme: {
|
||||
background: "#0b0b0b",
|
||||
@@ -749,11 +750,30 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
const reset = vi.fn(originalReset);
|
||||
terminal.reset = reset;
|
||||
|
||||
mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") });
|
||||
mounted.runtime.replay({ type: "end" });
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
mounted.runtime.renderSnapshot({
|
||||
state: {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
scrollback: [],
|
||||
grid: [
|
||||
[
|
||||
{ char: "p" },
|
||||
{ char: "r" },
|
||||
{ char: "o" },
|
||||
{ char: "m" },
|
||||
{ char: "p" },
|
||||
{ char: "t" },
|
||||
],
|
||||
],
|
||||
cursor: {
|
||||
row: 0,
|
||||
col: 6,
|
||||
},
|
||||
},
|
||||
});
|
||||
await nextFrame();
|
||||
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
|
||||
@@ -775,95 +795,3 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("incremental replay lifecycle", () => {
|
||||
it.each(["clear", "remount"] as const)(
|
||||
"recovers live input after interrupted replay and %s",
|
||||
async (cleanup) => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
|
||||
mounted.root.style.width = "360px";
|
||||
mounted.runtime.resize({ shouldClaim: false });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") });
|
||||
if (cleanup === "clear")
|
||||
await new Promise<void>((resolve) => mounted.runtime.clear({ onCommitted: resolve }));
|
||||
else {
|
||||
mounted.runtime.unmount();
|
||||
mounted.runtime.mount({
|
||||
root: mounted.root,
|
||||
host: mounted.host,
|
||||
scrollback: 1000,
|
||||
theme: {},
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
|
||||
);
|
||||
getBrowserTerminal().input("user-input", true);
|
||||
expect(mounted.inputs).toContain("user-input");
|
||||
expect(getBrowserTerminal().cols).toBeLessThan(60);
|
||||
},
|
||||
);
|
||||
|
||||
it("suppresses generated replies until queued replay chunks commit after end", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
mounted.runtime.replay({ type: "begin" });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
|
||||
mounted.runtime.replay({ type: "end" });
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }),
|
||||
);
|
||||
expect(mounted.inputs).toEqual([]);
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }),
|
||||
);
|
||||
expect(mounted.inputs).toHaveLength(1);
|
||||
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("restores fitted web geometry after replay before live output", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
await settleMountRefits();
|
||||
const local = latestSize(mounted.sizes);
|
||||
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("x".repeat(100)) });
|
||||
mounted.runtime.replay({ type: "end" });
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
|
||||
);
|
||||
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
|
||||
expect(getBrowserTerminal().cols).toBeLessThan(120);
|
||||
expect(getBrowserTerminal().rows).toBe(local.rows);
|
||||
mounted.runtime.resize({ forceClaim: true, shouldClaim: true });
|
||||
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
|
||||
});
|
||||
|
||||
it("defers layout fit until all replay chunks commit", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
await settleMountRefits();
|
||||
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({
|
||||
data: terminalOutput("first"),
|
||||
suppressInput: true,
|
||||
onCommitted: resolve,
|
||||
}),
|
||||
);
|
||||
expect(getBrowserTerminal().cols).toBe(120);
|
||||
mounted.root.style.width = "360px";
|
||||
mounted.runtime.resize({ shouldClaim: true, forceClaim: true, forceRefresh: true });
|
||||
await nextFrame();
|
||||
expect(getBrowserTerminal().cols).toBe(120);
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b[2;110Hsecond") });
|
||||
mounted.runtime.replay({ type: "end" });
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
|
||||
);
|
||||
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
|
||||
expect(getBrowserTerminal().cols).toBeLessThan(60);
|
||||
expect(latestSize(mounted.sizes)).toMatchObject({ shouldClaim: true, forceClaim: true });
|
||||
mounted.runtime.resize({ shouldClaim: true, forceClaim: true });
|
||||
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
|
||||
});
|
||||
|
||||
@@ -302,14 +302,25 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(committed).toEqual(["first", "second", "clear", "third"]);
|
||||
});
|
||||
|
||||
it("unblocks queued replay writes if a begin barrier never commits", () => {
|
||||
it("falls back to timeout commit for a barrier op when the gate sentinel never fires", () => {
|
||||
vi.useFakeTimers();
|
||||
const { runtime, writeTexts } = createRuntimeWithTerminal();
|
||||
runtime.replay({ type: "begin" });
|
||||
runtime.replay({ type: "data", data: terminalOutput("stuck") });
|
||||
expect(writeTexts).toEqual(["\x1bc"]);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(writeTexts).toEqual(["\x1bc", "stuck"]);
|
||||
const { runtime, terminal } = createRuntimeWithTerminal();
|
||||
const onCommitted = vi.fn();
|
||||
|
||||
// restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still
|
||||
// applies (and commits) after the 5s safety timeout instead of stalling forever.
|
||||
runtime.restoreOutput({
|
||||
data: terminalOutput("stuck"),
|
||||
onCommitted,
|
||||
});
|
||||
|
||||
expect(onCommitted).not.toHaveBeenCalled();
|
||||
expect(terminal.resetCalls).toBe(0);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
// The barrier now runs (write submitted) but its own commit still waits on xterm; advance
|
||||
// again to fire the barrier's own write timeout.
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(onCommitted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports input mode changes from terminal output and resets them on snapshots", () => {
|
||||
@@ -332,7 +343,18 @@ describe("terminal-emulator-runtime", () => {
|
||||
});
|
||||
|
||||
runtime.write({ data: terminalOutput("\x1b[>7u") });
|
||||
runtime.replay({ type: "begin", rows: 2, cols: 8 });
|
||||
runtime.renderSnapshot({
|
||||
state: {
|
||||
rows: 2,
|
||||
cols: 8,
|
||||
scrollback: [],
|
||||
grid: [[{ char: "$" }, { char: " " }]],
|
||||
cursor: {
|
||||
row: 0,
|
||||
col: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
// The plain write reports kitty flags synchronously during drain; the snapshot resets
|
||||
// them only after its barrier gate (the sentinel write callback) resolves.
|
||||
expect(inputModeChanges).toEqual([
|
||||
@@ -388,18 +410,40 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(committed).toEqual(["second", "first"]);
|
||||
});
|
||||
|
||||
it("keeps replay reset and data after preceding writes, without interleaving", () => {
|
||||
it("applies a snapshot after the writes ahead of it, never interleaving the reset", () => {
|
||||
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
|
||||
runtime.write({ data: terminalOutput("before-a") });
|
||||
runtime.write({ data: terminalOutput("before-b") });
|
||||
runtime.replay({ type: "begin" });
|
||||
runtime.replay({ type: "data", data: terminalOutput("snap") });
|
||||
runtime.replay({ type: "end" });
|
||||
const committed: string[] = [];
|
||||
|
||||
runtime.write({
|
||||
data: terminalOutput("before-a"),
|
||||
onCommitted: () => {
|
||||
committed.push("before-a");
|
||||
},
|
||||
});
|
||||
runtime.write({
|
||||
data: terminalOutput("before-b"),
|
||||
onCommitted: () => {
|
||||
committed.push("before-b");
|
||||
},
|
||||
});
|
||||
runtime.restoreOutput({
|
||||
data: terminalOutput("snap"),
|
||||
onCommitted: () => {
|
||||
committed.push("snap");
|
||||
},
|
||||
});
|
||||
|
||||
// Plain writes are submitted up front; the snapshot's reset+write has not run yet.
|
||||
expect(writeTexts).toEqual(["before-a", "before-b"]);
|
||||
|
||||
// writeCallbacks: [0]=before-a, [1]=before-b, [2]=barrier gate sentinel.
|
||||
// The snapshot is gated behind the prior writes; resolving the gate runs it after them.
|
||||
writeCallbacks[2]?.();
|
||||
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]);
|
||||
expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]);
|
||||
|
||||
// Snapshot commit fires only after its own write callback, strictly after the writes.
|
||||
writeCallbacks[3]?.();
|
||||
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]);
|
||||
expect(committed).toEqual(["snap"]);
|
||||
expect(terminal.resetCalls).toBe(0);
|
||||
});
|
||||
|
||||
@@ -412,7 +456,7 @@ describe("terminal-emulator-runtime", () => {
|
||||
|
||||
// No plain writes precede this barrier (mount), so there is nothing to gate: it starts
|
||||
// at once with no sentinel, flipping suppressInput synchronously.
|
||||
runtime.replay({ type: "begin" });
|
||||
runtime.restoreOutput({ data: terminalOutput("snapshot") });
|
||||
expect(readSuppressInput()).toBe(true);
|
||||
|
||||
// writeCallbacks[0] is the barrier's own snapshot write; committing it restores input.
|
||||
@@ -427,7 +471,7 @@ describe("terminal-emulator-runtime", () => {
|
||||
|
||||
// A plain write is now ungated, so the following barrier must wait on the sentinel.
|
||||
runtime.write({ data: terminalOutput("output") });
|
||||
runtime.replay({ type: "begin" });
|
||||
runtime.restoreOutput({ data: terminalOutput("snapshot") });
|
||||
|
||||
// The plain write commits first. Only the following sentinel opens the barrier.
|
||||
expect(readSuppressInput()).toBe(false);
|
||||
@@ -461,40 +505,57 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(onCommittedB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears ungated writes on unmount so remount replay begins immediately", () => {
|
||||
it("clears ungated writes on unmount so remount barriers apply immediately", () => {
|
||||
const { runtime } = createRuntimeWithTerminal();
|
||||
|
||||
runtime.write({ data: terminalOutput("before unmount") });
|
||||
runtime.unmount();
|
||||
|
||||
const remounted = attachStubTerminal(runtime);
|
||||
runtime.replay({ type: "begin" });
|
||||
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
|
||||
expect(remounted.writeTexts).toEqual(["\x1bc"]);
|
||||
remounted.writeCallbacks[0]?.();
|
||||
expect(remounted.writeTexts).toEqual(["\x1bc", "restored screen"]);
|
||||
runtime.restoreOutput({ data: terminalOutput("restored screen") });
|
||||
|
||||
expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]);
|
||||
expect(remounted.writeCallbacks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses replay chunks serially without synchronously resetting the terminal", () => {
|
||||
it("replays snapshots through a single write without first painting a reset terminal", () => {
|
||||
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
|
||||
runtime.replay({ type: "begin", rows: 2, cols: 8 });
|
||||
runtime.replay({ type: "data", data: terminalOutput("hi") });
|
||||
runtime.replay({ type: "data", data: terminalOutput("\r\n$") });
|
||||
runtime.replay({ type: "end" });
|
||||
expect(terminal.resetCalls).toBe(0);
|
||||
expect(writeTexts).toEqual(["\x1bc"]);
|
||||
|
||||
runtime.renderSnapshot({
|
||||
state: {
|
||||
rows: 2,
|
||||
cols: 8,
|
||||
scrollback: [],
|
||||
grid: [
|
||||
[{ char: "h" }, { char: "i" }],
|
||||
[{ char: "$" }, { char: " " }],
|
||||
],
|
||||
cursor: {
|
||||
row: 1,
|
||||
col: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The snapshot is a barrier; its write applies after the gate sentinel (writeCallbacks[0]).
|
||||
writeCallbacks[0]?.();
|
||||
expect(writeTexts).toEqual(["\x1bc", "hi"]);
|
||||
writeCallbacks[1]?.();
|
||||
expect(writeTexts).toEqual(["\x1bc", "hi", "\r\n$"]);
|
||||
|
||||
expect(terminal.resetCalls).toBe(0);
|
||||
expect(writeTexts).toHaveLength(1);
|
||||
expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true);
|
||||
expect(writeTexts[0]).toContain("hi");
|
||||
});
|
||||
|
||||
it("restores worker-rendered ANSI through the replay lifecycle", () => {
|
||||
it("restores server-rendered ANSI snapshots through the snapshot write path", () => {
|
||||
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
|
||||
runtime.replay({ type: "begin" });
|
||||
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
|
||||
runtime.replay({ type: "end" });
|
||||
|
||||
runtime.restoreOutput({ data: terminalOutput("restored screen") });
|
||||
|
||||
// restoreOutput is a barrier (suppressInput); it applies after the gate sentinel.
|
||||
writeCallbacks[0]?.();
|
||||
|
||||
expect(terminal.resetCalls).toBe(0);
|
||||
expect(writeTexts).toEqual(["\x1bc", "restored screen"]);
|
||||
expect(writeTexts).toEqual(["\u001bcrestored screen"]);
|
||||
});
|
||||
|
||||
it("forces a refit when resize is requested", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
type TerminalInputModeState,
|
||||
TerminalInputModeTracker,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
resolveMacTerminalEditingShortcut,
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
||||
import {
|
||||
createTerminalLocalFileLinkProvider,
|
||||
type TerminalLocalFileLinkSource,
|
||||
@@ -53,6 +55,7 @@ const FIND_HIGHLIGHT_LIMIT = 20_000;
|
||||
export interface TerminalEmulatorRuntimeMountInput {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollback: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
@@ -131,7 +134,7 @@ interface TerminalEmulatorRuntimeDisposables {
|
||||
}
|
||||
|
||||
interface TerminalOutputOperation {
|
||||
type: "write" | "clear" | "snapshot" | "fit";
|
||||
type: "write" | "clear" | "snapshot";
|
||||
data: TerminalOutputData;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
@@ -277,7 +280,6 @@ export class TerminalEmulatorRuntime {
|
||||
},
|
||||
};
|
||||
|
||||
private replayResize: TerminalResizeRequest | null = null;
|
||||
private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null;
|
||||
private lastSize: { rows: number; cols: number } | null = null;
|
||||
private cleanup: (() => void) | null = null;
|
||||
@@ -650,7 +652,6 @@ export class TerminalEmulatorRuntime {
|
||||
window.__paseoTerminal = terminal;
|
||||
|
||||
const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => {
|
||||
if (this.deferReplayResize(resizeInput)) return;
|
||||
const forceRefresh = resizeInput?.forceRefresh ?? false;
|
||||
const shouldClaim = resizeInput?.shouldClaim ?? true;
|
||||
const forceClaim = resizeInput?.forceClaim ?? false;
|
||||
@@ -776,6 +777,10 @@ export class TerminalEmulatorRuntime {
|
||||
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
|
||||
}, 0);
|
||||
|
||||
if (input.initialSnapshot) {
|
||||
this.renderSnapshot({ state: input.initialSnapshot });
|
||||
}
|
||||
|
||||
this.processOutputQueue();
|
||||
|
||||
const disposables: TerminalEmulatorRuntimeDisposables = {
|
||||
@@ -893,19 +898,20 @@ export class TerminalEmulatorRuntime {
|
||||
onFilePaste?.(file);
|
||||
}
|
||||
|
||||
replay(event: import("@getpaseo/client").TerminalReplayEvent): void {
|
||||
if (event.type === "begin") {
|
||||
this.replayResize ??= { shouldClaim: false };
|
||||
this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols });
|
||||
} else if (event.type === "data") {
|
||||
this.write({ data: event.data, suppressInput: true });
|
||||
} else {
|
||||
this.outputOperations.push({ type: "fit", data: EMPTY_TERMINAL_OUTPUT });
|
||||
this.processOutputQueue();
|
||||
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
|
||||
if (!input.state) {
|
||||
this.clear(input);
|
||||
return;
|
||||
}
|
||||
this.restoreOutput({
|
||||
data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
|
||||
rows: input.state.rows,
|
||||
cols: input.state.cols,
|
||||
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private restoreOutput(input: {
|
||||
restoreOutput(input: {
|
||||
data: TerminalOutputData;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
@@ -922,16 +928,6 @@ export class TerminalEmulatorRuntime {
|
||||
this.processOutputQueue();
|
||||
}
|
||||
|
||||
private deferReplayResize(input?: TerminalResizeRequest): boolean {
|
||||
if (!this.replayResize) return false;
|
||||
this.replayResize = {
|
||||
forceRefresh: this.replayResize.forceRefresh || input?.forceRefresh,
|
||||
shouldClaim: this.replayResize.shouldClaim || (input?.shouldClaim ?? true),
|
||||
forceClaim: this.replayResize.forceClaim || input?.forceClaim,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
resize(input?: TerminalResizeRequest): void {
|
||||
this.fitAndEmitResize?.(input);
|
||||
}
|
||||
@@ -1051,7 +1047,6 @@ export class TerminalEmulatorRuntime {
|
||||
commit();
|
||||
}
|
||||
this.hasUngatedWrites = false;
|
||||
this.replayResize = null;
|
||||
|
||||
this.cleanup?.();
|
||||
this.cleanup = null;
|
||||
@@ -1181,40 +1176,16 @@ export class TerminalEmulatorRuntime {
|
||||
this.processOutputQueue();
|
||||
};
|
||||
|
||||
if (operation.type === "fit") {
|
||||
// xterm resize flushes its write buffer synchronously. Leave the parser's
|
||||
// commit callback before fitting, or that flush consumes the next write.
|
||||
queueMicrotask(() => {
|
||||
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
|
||||
const resize = this.replayResize ?? { shouldClaim: false };
|
||||
this.replayResize = null;
|
||||
this.fitAndEmitResize?.(resize);
|
||||
finalizeOperation(operation);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.type === "clear") {
|
||||
this.inputModeDecoder.decode();
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
terminal.reset();
|
||||
if (!this.replayResize) {
|
||||
finalizeOperation(operation);
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
|
||||
const resize = this.replayResize ?? { shouldClaim: false };
|
||||
this.replayResize = null;
|
||||
this.fitAndEmitResize?.(resize);
|
||||
finalizeOperation(operation);
|
||||
});
|
||||
finalizeOperation(operation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.type === "snapshot") {
|
||||
this.replayResize ??= { shouldClaim: false };
|
||||
this.inputModeDecoder.decode();
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
|
||||
@@ -31,6 +31,7 @@ function mount(options?: { isMac: boolean }) {
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 1000,
|
||||
theme: { background: "#ffffff", foreground: "#111111" },
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ function mountTerminal(left: number): TerminalView {
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 1000,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 16,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
@@ -58,6 +57,7 @@ function mount(width: number, height: number): Mounted {
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 10_000,
|
||||
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" },
|
||||
});
|
||||
@@ -90,15 +90,9 @@ function someRowContains(term: InspectableTerminal, text: string): boolean {
|
||||
return dumpRows(term).some((r) => r.text.includes(text));
|
||||
}
|
||||
|
||||
function replayCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
|
||||
function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
|
||||
return new Promise<void>((resolve) => {
|
||||
runtime.replay({ type: "begin", rows: state.rows, cols: state.cols });
|
||||
runtime.replay({
|
||||
type: "data",
|
||||
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(state)),
|
||||
});
|
||||
runtime.replay({ type: "end" });
|
||||
runtime.write({ data: new Uint8Array([0]), onCommitted: resolve });
|
||||
runtime.renderSnapshot({ state, onCommitted: resolve });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,7 +149,7 @@ describe("terminal resize reflow repro (Paseo terminal)", () => {
|
||||
|
||||
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
|
||||
// long line wrapped at the server width as separate grid rows.
|
||||
await replayCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
|
||||
await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
|
||||
await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]"));
|
||||
|
||||
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
|
||||
|
||||
@@ -4,27 +4,25 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
|
||||
|
||||
describe("terminal restore options", () => {
|
||||
it("omits restore options for daemons without terminal restore modes", () => {
|
||||
expect(() =>
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: false,
|
||||
canClaimSize: true,
|
||||
size: { rows: 24, cols: 80 },
|
||||
}),
|
||||
).toThrow("Update the host to view terminals.");
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requests visible snapshot restore with configured scrollback for capable daemons", () => {
|
||||
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: true,
|
||||
canClaimSize: true,
|
||||
size: { rows: 24, cols: 80 },
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: 10000,
|
||||
scrollbackLines: 200,
|
||||
size: { rows: 24, cols: 80 },
|
||||
});
|
||||
});
|
||||
@@ -32,39 +30,26 @@ describe("terminal restore options", () => {
|
||||
it("omits size until the terminal has been measured", () => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: true,
|
||||
canClaimSize: true,
|
||||
size: null,
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: 10000,
|
||||
scrollbackLines: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a background attach resize the PTY", () => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: true,
|
||||
canClaimSize: false,
|
||||
size: { rows: 24, cols: 80 },
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: 10000,
|
||||
scrollbackLines: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
supportsTerminalRestoreModes: true,
|
||||
scrollbackLines,
|
||||
canClaimSize: false,
|
||||
size: null,
|
||||
}),
|
||||
).toEqual({ mode: "visible-snapshot", scrollbackLines });
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
|
||||
|
||||
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
|
||||
|
||||
export interface ResolveTerminalRestoreOptionsInput {
|
||||
supportsTerminalRestoreModes: boolean;
|
||||
scrollbackLines: number;
|
||||
canClaimSize: boolean;
|
||||
size: { rows: number; cols: number } | null;
|
||||
}
|
||||
|
||||
export function resolveTerminalRestoreOptions(
|
||||
input: ResolveTerminalRestoreOptionsInput,
|
||||
): NonNullable<SubscribeTerminalRequest["restore"]> {
|
||||
): SubscribeTerminalRequest["restore"] | undefined {
|
||||
if (!input.supportsTerminalRestoreModes) {
|
||||
throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate"));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
|
||||
...(input.canClaimSize && input.size ? { size: input.size } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Terminal as HeadlessTerminal } from "@xterm/headless";
|
||||
import { Terminal as ClientTerminal } from "@xterm/xterm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
||||
|
||||
interface SnapshotCell {
|
||||
char: string;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
@@ -1,4 +1,3 @@
|
||||
import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -8,16 +7,34 @@ import {
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "./terminal-stream-controller";
|
||||
|
||||
interface TerminalSnapshot {
|
||||
rows: number;
|
||||
cols: number;
|
||||
grid: Array<Array<{ char: string }>>;
|
||||
scrollback: Array<Array<{ char: string }>>;
|
||||
cursor: { row: number; col: number };
|
||||
}
|
||||
|
||||
function terminalCellText(cell: { char: string }): string {
|
||||
return cell.char;
|
||||
}
|
||||
|
||||
function terminalRowText(row: Array<{ char: string }>): string {
|
||||
return row.map(terminalCellText).join("");
|
||||
}
|
||||
|
||||
function terminalSnapshotText(state: TerminalSnapshot): string {
|
||||
return state.grid.map(terminalRowText).join("\n");
|
||||
}
|
||||
|
||||
function terminalOutput(text: string): Uint8Array {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
type StreamEvent = import("@getpaseo/client").TerminalStreamEvent;
|
||||
type TerminalStreamEvent = StreamEvent extends infer Event
|
||||
? Event extends StreamEvent
|
||||
? Omit<Event, "subscriptionId">
|
||||
: never
|
||||
: never;
|
||||
type TerminalStreamEvent =
|
||||
| { terminalId: string; type: "output"; data: Uint8Array }
|
||||
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
|
||||
| { terminalId: string; type: "restore"; data: Uint8Array };
|
||||
|
||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
private readonly updates = new Set<(message: SessionOutboundMessage) => void>();
|
||||
@@ -98,23 +115,32 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
|
||||
const client = input?.client ?? new FakeTerminalStreamClient();
|
||||
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
|
||||
const replays: import("@getpaseo/client").TerminalReplayEvent[] = [];
|
||||
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
|
||||
const snapshots: Array<{ terminalId: string; text: string }> = [];
|
||||
const statuses: TerminalStreamControllerStatus[] = [];
|
||||
const exits: string[] = [];
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
onReplay: ({ replay }) => replays.push(replay),
|
||||
onExit: (terminalId) => exits.push(terminalId),
|
||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||
onOutput: (output) => {
|
||||
outputs.push(output);
|
||||
},
|
||||
onRestore: (restore) => {
|
||||
restores.push(restore);
|
||||
},
|
||||
onSnapshot: ({ terminalId, state }) => {
|
||||
snapshots.push({
|
||||
terminalId,
|
||||
text: terminalSnapshotText(state),
|
||||
});
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
statuses.push(status);
|
||||
},
|
||||
});
|
||||
|
||||
return { client, controller, outputs, replays, statuses, exits };
|
||||
return { client, controller, outputs, restores, snapshots, statuses, exits };
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
@@ -123,20 +149,24 @@ async function flushAsyncWork(): Promise<void> {
|
||||
}
|
||||
|
||||
describe("terminal-stream-controller", () => {
|
||||
it("subscribes, resizes, and forwards replay/output events", async () => {
|
||||
it("subscribes, resizes, and forwards snapshot/output events", async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
|
||||
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
|
||||
{ type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 },
|
||||
{ type: "data", data: terminalOutput("hello") },
|
||||
{ type: "end" },
|
||||
];
|
||||
for (const event of replay)
|
||||
harness.client.emit({ terminalId: "term-1", type: "replay", replay: event });
|
||||
harness.client.emit({
|
||||
terminalId: "term-1",
|
||||
type: "snapshot",
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 5,
|
||||
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 5 },
|
||||
},
|
||||
});
|
||||
const outputData = terminalOutput(" world");
|
||||
harness.client.emit({
|
||||
terminalId: "term-1",
|
||||
@@ -148,7 +178,7 @@ describe("terminal-stream-controller", () => {
|
||||
expect(harness.client.resizeCalls).toEqual([
|
||||
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
|
||||
]);
|
||||
expect(harness.replays).toEqual(replay);
|
||||
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
|
||||
expect(harness.outputs[0]?.data).toBe(outputData);
|
||||
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
|
||||
expect(harness.statuses.at(-1)).toEqual({
|
||||
@@ -206,19 +236,23 @@ describe("terminal-stream-controller", () => {
|
||||
size: { rows: 24, cols: 80 },
|
||||
}),
|
||||
onOutput: (output) => harness.outputs.push(output),
|
||||
onReplay: ({ replay }) => harness.replays.push(replay),
|
||||
onRestore: (restore) => harness.restores.push(restore),
|
||||
onSnapshot: ({ terminalId, state }) => {
|
||||
harness.snapshots.push({
|
||||
terminalId,
|
||||
text: terminalSnapshotText(state),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
const restoreData = terminalOutput("restored");
|
||||
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
|
||||
{ type: "begin" },
|
||||
{ type: "data", data: restoreData },
|
||||
{ type: "end" },
|
||||
];
|
||||
for (const event of replay)
|
||||
client.emit({ terminalId: "term-1", type: "replay", replay: event });
|
||||
client.emit({
|
||||
terminalId: "term-1",
|
||||
type: "restore",
|
||||
data: restoreData,
|
||||
});
|
||||
controller.dispose();
|
||||
|
||||
expect(client.subscribeCalls).toEqual([
|
||||
@@ -236,7 +270,8 @@ describe("terminal-stream-controller", () => {
|
||||
expect(client.resizeCalls).toEqual([
|
||||
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
|
||||
]);
|
||||
expect(harness.replays).toEqual(replay);
|
||||
expect(harness.restores[0]?.data).toBe(restoreData);
|
||||
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
|
||||
expect(harness.outputs).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -284,28 +319,3 @@ it("reports observation failure without declaring the PTY exited, then allows re
|
||||
expect(h.statuses.at(-1)?.error).toBe(null);
|
||||
h.controller.dispose();
|
||||
});
|
||||
|
||||
it("does not resize or attach on a JSON-era host and reports the required update", () => {
|
||||
const client = new FakeTerminalStreamClient();
|
||||
const statuses: TerminalStreamControllerStatus[] = [];
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => ({ rows: 24, cols: 40 }),
|
||||
onOutput: () => {},
|
||||
getRestoreOptions: () =>
|
||||
resolveTerminalRestoreOptions({
|
||||
supportsTerminalRestoreModes: false,
|
||||
scrollbackLines: 10000,
|
||||
canClaimSize: true,
|
||||
size: { rows: 24, cols: 40 },
|
||||
}),
|
||||
onStatusChange: (status) => statuses.push(status),
|
||||
});
|
||||
controller.setTerminal({ terminalId: "old-host-terminal" });
|
||||
expect(client.subscribeCalls).toEqual([]);
|
||||
expect(client.resizeCalls).toEqual([]);
|
||||
expect(statuses.at(-1)).toMatchObject({
|
||||
isAttaching: false,
|
||||
error: "Update the host to view terminals.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
|
||||
import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalOutputData } from "./terminal-emulator-runtime";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -23,11 +23,9 @@ export interface TerminalStreamControllerOptions {
|
||||
client: TerminalStreamControllerClient;
|
||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
|
||||
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
|
||||
onExit?: (terminalId: string) => void;
|
||||
onReplay?: (input: {
|
||||
terminalId: string;
|
||||
replay: import("@getpaseo/client").TerminalReplayEvent;
|
||||
}) => void;
|
||||
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
|
||||
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
|
||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||
}
|
||||
@@ -45,11 +43,17 @@ export class TerminalStreamController {
|
||||
if (this.disposed || event.terminalId !== this.terminalId) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "replay") {
|
||||
this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay });
|
||||
if (event.type === "snapshot") {
|
||||
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
|
||||
return;
|
||||
}
|
||||
if (event.type === "output" && event.data.length > 0) {
|
||||
if (event.type === "restore") {
|
||||
if (event.data.length > 0) {
|
||||
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.data.length > 0) {
|
||||
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
|
||||
}
|
||||
};
|
||||
@@ -66,10 +70,10 @@ export class TerminalStreamController {
|
||||
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
|
||||
return;
|
||||
}
|
||||
const restore = this.options.getRestoreOptions?.();
|
||||
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
|
||||
let subscription: ReturnType<DaemonClient["observeTerminal"]>;
|
||||
try {
|
||||
const restore = this.options.getRestoreOptions?.();
|
||||
const preferredSize = restore?.size ?? this.options.getPreferredSize();
|
||||
if (preferredSize)
|
||||
this.options.client.sendTerminalInput(nextTerminalId, {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getWorkspaceTerminalSession,
|
||||
releaseWorkspaceTerminalSession,
|
||||
retainWorkspaceTerminalSession,
|
||||
} from "./workspace-terminal-session";
|
||||
|
||||
describe("workspace-terminal-session", () => {
|
||||
it("returns the same workspace session instance for the same scope", () => {
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-a",
|
||||
});
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-a",
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("preserves snapshots across repeated lookups", () => {
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-snapshots",
|
||||
});
|
||||
first.snapshots.set({
|
||||
terminalId: "term-1",
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-snapshots",
|
||||
});
|
||||
|
||||
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts workspace terminal session state when the retain count returns to zero", () => {
|
||||
const scopeKey = "workspace-release";
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
});
|
||||
first.snapshots.set({
|
||||
terminalId: "term-1",
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
retainWorkspaceTerminalSession({ scopeKey });
|
||||
releaseWorkspaceTerminalSession({ scopeKey });
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
|
||||
export interface WorkspaceTerminalSnapshots {
|
||||
get: (input: { terminalId: string }) => TerminalState | null;
|
||||
set: (input: { terminalId: string; state: TerminalState }) => void;
|
||||
clear: (input: { terminalId: string }) => void;
|
||||
prune: (input: { terminalIds: string[] }) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceTerminalSession {
|
||||
scopeKey: string;
|
||||
snapshots: WorkspaceTerminalSnapshots;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalSessionRecord {
|
||||
snapshotByTerminalId: Map<string, TerminalState>;
|
||||
session: WorkspaceTerminalSession;
|
||||
}
|
||||
|
||||
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
|
||||
const refCountByScopeKey = new Map<string, number>();
|
||||
|
||||
function createSnapshots(input: {
|
||||
snapshotByTerminalId: Map<string, TerminalState>;
|
||||
}): WorkspaceTerminalSnapshots {
|
||||
return {
|
||||
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
|
||||
set: ({ terminalId, state }) => {
|
||||
input.snapshotByTerminalId.set(terminalId, state);
|
||||
},
|
||||
clear: ({ terminalId }) => {
|
||||
input.snapshotByTerminalId.delete(terminalId);
|
||||
},
|
||||
prune: ({ terminalIds }) => {
|
||||
const terminalIdSet = new Set(terminalIds);
|
||||
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
input.snapshotByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
|
||||
const existing = sessionsByScopeKey.get(input.scopeKey);
|
||||
if (existing) {
|
||||
return existing.session;
|
||||
}
|
||||
|
||||
const snapshotByTerminalId = new Map<string, TerminalState>();
|
||||
const session: WorkspaceTerminalSession = {
|
||||
scopeKey: input.scopeKey,
|
||||
snapshots: createSnapshots({
|
||||
snapshotByTerminalId,
|
||||
}),
|
||||
};
|
||||
|
||||
sessionsByScopeKey.set(input.scopeKey, {
|
||||
snapshotByTerminalId,
|
||||
session,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
refCountByScopeKey.set(input.scopeKey, current + 1);
|
||||
}
|
||||
|
||||
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
if (current > 1) {
|
||||
refCountByScopeKey.set(input.scopeKey, current - 1);
|
||||
return;
|
||||
}
|
||||
refCountByScopeKey.delete(input.scopeKey);
|
||||
sessionsByScopeKey.delete(input.scopeKey);
|
||||
}
|
||||
@@ -371,22 +371,6 @@ describe("viewed timeline persistence", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preparing an already painted cache again keeps history out of the live head", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined },
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
await replica.prepare(AGENT_ID);
|
||||
const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID);
|
||||
expect(painted).toEqual(cachedTimeline().items);
|
||||
await replica.prepare(AGENT_ID);
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID]!;
|
||||
expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]);
|
||||
expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted);
|
||||
});
|
||||
|
||||
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user