Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e522e6ea8e fix(app): make the compact Explorer tab rail usable on phones (#7589)
Popovers on Android landed one status bar below their trigger. React Native
0.86 stopped subtracting the status bar from window coordinates under
edge-to-edge, so measureInWindow, touch pageY and a full-screen Modal already
share screen-top coordinates; the old StatusBar.currentHeight compensation in
the menu engine, tooltip, context menu and sidebar long-press now
double-shifted every anchor.

Compact tabs keep their natural width and the rail scrolls as soon as they
overflow, instead of squeezing labels first and then scrolling anyway. They
drop the inline close because the long-press sheet already closes a tab and a
narrow X beside the label is easy to hit. Any scrolling rail keeps natural
widths rather than collapsing every chip to the minimum, and the rail resets
its offset when scrolling ends so tabs scrolled out of view come back.

The Explorer rail paints the sidebar surface, so its chips climb the sidebar
hover and selected tokens. Light themes paint the sidebar with surface2, which
made the surface2 active chip invisible there.

The compact Explorer closes with the same X as the compact agent list, and the
compact + menu omits Diff because compact Changes already shows diffs inline.
2026-10-21 21:44:53 +07:00
1e0a9d5af0 fix(plugins): allow notification timeline transformers (#7582)
Co-authored-by: 陈睿 <chenrui.92@dcarlife.com>
Co-authored-by: Gavin Chen <chenrui920614@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 14:57:22 +02:00
Mohamed Boudra 995ad2f878 feat(plugin): render Markdown content with host UI (#7579)
* feat(plugin): expose Markdown content component

* docs(plugin): translate Markdown component reference
2026-10-21 19:14:17 +07:00
Mohamed Boudra 1d383519dd feat(app): always show the host picker with Add host on New workspace (#7580)
The Host field used to render only with two or more hosts, and Add host
only appeared on compact layouts. Single-host users had no sign that
Paseo can drive other machines from this screen. The field now always
renders and its menu always ends with Add host.
2026-10-21 18:56:12 +07:00
Mohamed Boudra 23c4df4818 Fix agents showing running after daemon restart (#7573)
* fix(server): close stale agent status after daemon restart

Repair persisted active statuses before serving clients. Let Claude process-tree cleanup run immediately after SDK close rather than waiting on an already closed control channel.

* test(app): expect closed agent status after daemon restart
2026-10-21 18:28:31 +07:00
Mohamed Boudra c63e1204b0 Review all branch changes by default without automatic scope switching (#7576)
* feat(git): default review to all branch changes

Keep the selected comparison stable while agents edit and commit so reviewing the complete result does not require switching scopes or interrupt unfinished comments.

* test(git): release Windows checkout handles during cleanup

Allow transient filesystem handles from subscription teardown to close before removing the test repository.
2026-10-21 18:28:23 +07:00
Mohamed Boudra d0568f9f1b Batch agent reports and completion notifications (#7561)
* fix(server): batch agent reports and completion notifications

Admit agent-originated messages immediately so a child can report and finish before one recipient-owned delivery. Keep human prompts direct, retain individual provenance through replay, and complete send receipts only after provider dispatch.

* test(server): wire queued delivery into bridge integration fixture

* test(server): resolve batching CLI fixture independently of cwd

Workspace CI runs from packages/server, while the original regression was verified from the repository root. Resolve the CLI entrypoint relative to this test file in both contexts.

* test(server): run batching CLI fixture with source exports

Server integration CI builds only the client dependency stack. Match the existing CLI subprocess fixtures so the send regression exercises workspace sources without requiring compiled server output.

* test(server): await persisted batch receipts within integration deadline

The provider-start callback precedes asynchronous receipt commits. Use the same explicit integration deadline as dispatch instead of the matcher default of one second on Windows CI.
2026-10-21 18:28:03 +07:00
paseo-ai[bot] 5878680f7f fix: update lockfile signatures and Nix hash [skip ci] 2026-10-21 09:20:13 +00:00
Mohamed Boudra 91dca5702b Connect saved hosts and delegate agents across daemons (#7370)
* feat(hosts): connect to saved hosts through a daemon

* fix(hosts): validate safe targets and verify encrypted relay routes

* test(hosts): include relay coverage in existing CI contracts

* fix(cli): preserve validated addresses in daemon command hints

* fix(hosts): share daemon links across isolated routed sessions

Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion.

* fix(hosts): bound encrypted overload and initial acquisition

Check encrypted wire capacity before the async channel converts a resource limit into a physical connection error. Bound initial routed acquisition with the existing client timeout, then enable reconnect after admission.

* fix(hosts): keep compact row actions clear of host labels

Give compact connected-host rows separate text and action widths so long names and producer labels cannot compete with primary controls.

* refactor(hosts): remove management settings UI from phase one

Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access.

* test(hosts): control physical write backpressure across platforms

Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks.

* refactor(hosts): use the ordinary client contract over channel transports

Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes.

* test(e2e): keep transient developer toasts from stalling checks

Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions.

* Create, prompt, and monitor agents across connected hosts (#7507)

* feat(hosts): delegate agents across connected hosts

Resolve MCP host operations through in-process commands for self and the existing routed client transport for other destinations. Keep ordinary parent labels, app sessions and idle finish notifications.

* test(app): normalize subagent fixtures through snapshots

* docs(protocol): clarify same-host parent labels

* fix(hosts): resolve subagents through destination sessions

A child address identifies its destination regardless of the connection used. Reuse the existing host candidate selection for discovered routes and direct connections, and keep navigation and row actions independent of the parent transport.

* fix(hosts): keep routed destinations distinct during discovery

A routed candidate names only the origin daemon. Match it within the named destination profile so two destinations discovered through the same origin retain separate identities and connections, including existing saved routes.

* feat(hosts): prompt and query agents on selected hosts

Extend the existing MCP host operation boundary with prompt, status, activity and list operations. Local calls retain their handlers and notification owner; selected destinations use ordinary client RPCs and the existing idle finish wait.

* test(e2e): preserve toast coverage with CSS hiding

* docs(hosts): translate connected host references
2026-10-21 16:10:47 +07:00
5d70bf0645 fix(android): restore image picker after Activity recreation (#7546)
Backport expo/expo#49634 to SDK 54 so retained launchers register
against the current Activity after their previous owner is destroyed.
Cover repeated recreation and replacement-before-destroy ordering with
native regression tests, and document the native rebuild requirement.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: Ido Yana <idoyana@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 09:17:26 +02:00
Mohamed Boudra fbd90cb88d Keep setup execution and output owned by the workspace (#7473)
* perf(workspaces): stop duplicating setup output in progress snapshots

Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly.

* refactor(workspaces): own setup exclusively at the workspace boundary

Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas.

* fix(server): avoid duplicate cwd in worktree provisioning request

* test(e2e): keep developer toasts out of concurrent app assertions

Concurrent layout measurements could invoke the same Playwright locator handler before its first dismissal finished, leaving checks waiting for a removed button. Hide only the Expo toast host through test styling while retaining console error capture.
2026-10-21 14:16:24 +07:00
paseo-ai[bot] a891155b82 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-21 06:55:06 +00:00
Mohamed Boudra 006fd053ac Record Android freezes in opt-in diagnostic builds (#7456)
* feat(app): record Android stalls in opt-in diagnostic builds

* refactor(app): own watchdog sinks and bounded log storage

* fix(app): preserve cumulative browser profiling history
2026-10-21 13:48:05 +07:00
Mohamed Boudra 2545250b2f fix(app): keep saved workspace tabs when a store changes before hydration (#7547)
On every page load the layout mode publisher sets the workspace layout store
before its stored state has been read. Zustand persist wrote the unhydrated
defaults, and hydration merges the stored layout into memory without writing
it back, so storage held no tabs until the next layout change. Leaving the
page inside that window lost every workspace's tabs. Validated persist storage
now drops writes that arrive while its read is in flight.
2026-10-21 13:45:16 +07:00
Mohamed Boudra 30464cf234 Translate plugin content and labels in each app language (#7478)
* feat(website): serve paseo.sh in Simplified Chinese

Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.

* feat(website): replace the native language select with an EN/ZH menu

The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.

* docs(website): translate main's new docs and 0.12.0 release notes

Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.

* fix(server): leave plugin listing translations to the registry

The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.

* docs(plugins): describe the registry's final overview translation discovery

* style(server): format manifest test

* feat(plugins): translate plugin UI in each app language

Keep translation resources and subscriptions inside each installation. Resolve host labels against the presentation locale so live language changes preserve mounted drafts and RPC state. Existing descriptor types retain string defaults for source compatibility.

* test(plugins): dismiss developer chrome in second translation client

* test(app): measure sidebar rows one at a time

After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.

* test(app): return sidebar row measuring to main

#7480 hides the developer toast instead of running a locator handler, so
measuring the rows concurrently no longer races it. Reverts 41ac23016.

* test(app): wait for the compact shell before opening compact Changes

The shell survives the breakpoint, so right after the resize the wide
Explorer could still be visible: the test skipped the Explorer toggle and
the tab then disappeared, or toggled at the wrong time. Wait for the
compact-shell marker, as the composer helpers do since #7424.

* docs(website): translate main's plugin file panel docs
2026-10-21 13:40:46 +07:00
Mohamed Boudra 98d34a3dc1 ci: run pull-request checks for every base branch (#7545)
Stacked PRs and PRs into next got no CI because the pull_request trigger
was limited to main. Drop the base filter from CI and desktop packaging,
and lock it with a workflow contract test.
2026-10-21 13:32:10 +07:00
paseo-ai[bot] 77453388e4 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-21 06:22:06 +00:00
Mohamed Boudra bd15e40e94 Serve paseo.sh in Simplified Chinese (#7373)
* feat(website): serve paseo.sh in Simplified Chinese

Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.

* feat(website): replace the native language select with an EN/ZH menu

The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.

* docs(website): translate main's new docs and 0.12.0 release notes

Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.

* fix(server): leave plugin listing translations to the registry

The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.

* docs(plugins): describe the registry's final overview translation discovery

* style(server): format manifest test

* test(app): measure sidebar rows one at a time

After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.

* test(app): return sidebar row measuring to main

#7480 hides the developer toast instead of running a locator handler, so
measuring the rows concurrently no longer races it. Reverts 41ac23016.

* test(app): wait for the compact shell before opening compact Changes

The shell survives the breakpoint, so right after the resize the wide
Explorer could still be visible: the test skipped the Explorer toggle and
the tab then disappeared, or toggled at the wrong time. Wait for the
compact-shell marker, as the composer helpers do since #7424.

* docs(website): translate main's plugin file panel docs

* test(app): edit the code font size after the font family remount

A committed appearance change remounts the settings screen through
AppearanceStyleBoundary, and the new form starts from the stored size.
Filling the size before that remount lost the edit in CI.

* docs(website): translate main's client/ui import docs

Follows #7474: one /client/ui entry, StreamedText instead of useRevealedText,
SettingsGroup dropped from the settings table, and the new
'UI imports in v0.13' migration section.

* test(app): wait for the previous layout's file panel picker to leave

After a resize the old layout's picker animates out while the new one is
already mounted, so the Use button matched two elements and the strict
locator failed. Wait for the shell's layout and a single picker first.
2026-10-21 13:13:03 +07:00
paseo-bot[bot] c70a6cfb9e fix(app): resolve Markdown preview links from their document (#7538)
Preview links used the workspace root even when the document was nested. Carry a separate relative-path base through the file-link resolver so clicks and hover paths agree while chat keeps workspace-relative resolution.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 06:56:12 +02:00
Mohamed Boudra 9bce7dbd25 refactor(plugin): unify the client UI entrypoint (#7474)
* fix(files): report document MIME types for binary files

File reads now carry the document's MIME type for binary files, so a file panel can
hand the bytes to a viewer that needs the real type (application/pdf).

* feat(plugins): file panels

Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the
file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor,
which claims every text file and is always the last candidate. The panel is the tab body;
the bar shows the document's status and a switcher between the file's panels (side by side
up to three, two on a phone, a menu beyond that).

Paseo asks which panel opens a file only when a plugin panel is among several candidates,
once per open, and remembers the answer per extension or exact filename when told to. Open
with in the tab menu and the switcher change the tab's panel; Always use remembers it.
Opening a file at a line switches the tab to Source.

Panels read and write the host's document, so dirty state, autosave and conflicts stay
Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer.

Replaces the file tab's Preview/Source mode toggle.

* feat(file-panels): choose and forget file panels in Settings > Files

Settings > Files lists each extension or filename where a plugin panel competes with
another candidate, or that has a remembered panel. Each row chooses the panel that opens
it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for
text files. Panels no connected host offers can be forgotten but not chosen.

* docs(plugins): file panel examples and author reference

Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin
reference, and an e2e spec that installs both examples, opens a PDF in the browser
viewer, and edits Markdown through Paseo's document across the switcher, Source,
autosave and conflicts.

* feat(file-panels): panels read and write through a file handle

The file tab stats the file and mounts its panel without reading it. Every
panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url,
version-checked writeText/writeBytes (PluginFileConflictError unless force),
and watch. useFileText, useFileBytes and useFileUrl read through it, and
Loading joins @getpaseo/plugin/client/ui. Panels own their loading state.

The host document pipeline, the explorer preview cache and the preview
lifecycle are deleted. The daemon classifies a file version from a bounded
sniff and writes base64 content as bytes behind fileByteWrites.

A deleted file keeps its panel mounted; a read confirms the deletion first,
so a remove-and-rewrite is not reported as one. Native Source decodes with
builtins and highlights in idle-time slices, the lines around the opening
line first, so a large file no longer blocks the JS thread.

* fix(file-panels): Settings offers Source where the picker does, and review follow-ups

- Settings > Files offers Source on every key except those a built-in panel of
  another kind (Image) claims, so a plugin that alone claims a text extension
  shows the same choices as the picker.
- Delete the unused isSourceFilePanel.
- The Markdown example warns with the file name when its unmount write fails,
  rather than dropping the edit silently.
- Document that PluginFile stat fields are read at render time and that watch
  or the read hooks are how a panel learns they changed.

* refactor(plugin): consolidate the client UI entrypoint
2026-10-21 11:12:22 +07:00
18eabeedab Archive agents beyond the first directory page (#7535)
* fix(cli): resolve agent archive through the daemon, not a listing page

`paseo agent archive` resolved its argument against a single
`fetch_agents` response. That listing is capped at 200 entries per page
and the command never pages, so an agent past the cap could not be
archived: the CLI reported AGENT_NOT_FOUND before any request reached the
daemon, while `paseo agent ls --global` and `paseo agent inspect` still
returned it.

Resolve the argument with `fetch_agent` instead, the same authoritative
daemon lookup `agent inspect`, `agent stop` and `agent delete` use. The
already-archived and running checks read the returned record unchanged.

Closes #4909

* fix(cli): only report a missing agent as AGENT_NOT_FOUND in archive

The lookup rejection was mapped to AGENT_NOT_FOUND whatever it said, so
an ambiguous identifier, a timeout or a daemon handler failure all read
as a missing agent. The daemon reports an unknown identifier only as an
error string, so recognize that one rejection by its text and rethrow
anything else unchanged.

---------

Co-authored-by: L4XB <lukas.buck@e-mail.de>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HTeo <hteo1337@gmail.com>
2026-10-21 05:52:11 +02:00
Mohamed Boudra e464fd3bd3 feat(plugins): file panels (#7411)
* fix(files): report document MIME types for binary files

File reads now carry the document's MIME type for binary files, so a file panel can
hand the bytes to a viewer that needs the real type (application/pdf).

* feat(plugins): file panels

Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the
file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor,
which claims every text file and is always the last candidate. The panel is the tab body;
the bar shows the document's status and a switcher between the file's panels (side by side
up to three, two on a phone, a menu beyond that).

Paseo asks which panel opens a file only when a plugin panel is among several candidates,
once per open, and remembers the answer per extension or exact filename when told to. Open
with in the tab menu and the switcher change the tab's panel; Always use remembers it.
Opening a file at a line switches the tab to Source.

Panels read and write the host's document, so dirty state, autosave and conflicts stay
Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer.

Replaces the file tab's Preview/Source mode toggle.

* feat(file-panels): choose and forget file panels in Settings > Files

Settings > Files lists each extension or filename where a plugin panel competes with
another candidate, or that has a remembered panel. Each row chooses the panel that opens
it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for
text files. Panels no connected host offers can be forgotten but not chosen.

* docs(plugins): file panel examples and author reference

Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin
reference, and an e2e spec that installs both examples, opens a PDF in the browser
viewer, and edits Markdown through Paseo's document across the switcher, Source,
autosave and conflicts.

* feat(file-panels): panels read and write through a file handle

The file tab stats the file and mounts its panel without reading it. Every
panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url,
version-checked writeText/writeBytes (PluginFileConflictError unless force),
and watch. useFileText, useFileBytes and useFileUrl read through it, and
Loading joins @getpaseo/plugin/client/ui. Panels own their loading state.

The host document pipeline, the explorer preview cache and the preview
lifecycle are deleted. The daemon classifies a file version from a bounded
sniff and writes base64 content as bytes behind fileByteWrites.

A deleted file keeps its panel mounted; a read confirms the deletion first,
so a remove-and-rewrite is not reported as one. Native Source decodes with
builtins and highlights in idle-time slices, the lines around the opening
line first, so a large file no longer blocks the JS thread.

* fix(file-panels): Settings offers Source where the picker does, and review follow-ups

- Settings > Files offers Source on every key except those a built-in panel of
  another kind (Image) claims, so a plugin that alone claims a text extension
  shows the same choices as the picker.
- Delete the unused isSourceFilePanel.
- The Markdown example warns with the file name when its unmount write fails,
  rather than dropping the edit silently.
- Document that PluginFile stat fields are read at render time and that watch
  or the read hooks are how a panel learns they changed.

* fix(plugins): file events are objects, deletion is a typed error, DocumentFrame owns its fallback

- watch listeners receive { type: "changed" } | { type: "deleted" }, so later
  versions can add fields without breaking them.
- PluginFileDeletedError joins PluginFileConflictError. The read hooks report a
  deleted file with it, and a write over a deleted file rejects with it; Source
  shows that as the deleted notice. PluginFileConflictError now means only
  "changed on disk".
- DocumentFrame on Android shows Paseo's "<TYPE> files can't be shown on this
  device" message for documents its WebView cannot display, so the PDF example
  is useFileUrl + Loading + DocumentFrame with no platform branch.

* test(file-panels): Settings offers Source for a remembered panel no host has

* test(e2e): hide the developer error toast instead of clicking it away

The locator handler that dismissed #error-toast could match a toast that closed before its
click, then wait on it until the test timed out (agent-timeline-resume.spec.ts:45 failed every
attempt on this branch). Hiding the toast with CSS keeps it off app actions with no race.

* test(e2e): desktop browser-tabs hides developer toasts on its attached page
2026-10-21 10:49:04 +07:00
Mohamed Boudra b36ff056ea fix(android): restore development builds after Expo upgrade (#7476)
* fix(android): restore development builds after Expo upgrade

Port the native touch-cancellation patch to the gesture-handler API while retaining drawing order and surviving native gestures. Match the bundled Skia font scanner and Unicode paragraph construction APIs.

* test(e2e): hide the developer error toast instead of clicking it away

The locator handler that dismissed #error-toast could match a toast that closed before its
click; dispatchEvent then waited for the gone button until the test timed out. Hiding the toast
with CSS keeps it off app actions with no race.

* test(e2e): desktop browser-tabs hides developer toasts on its attached page

* test(cli): keep the Hub deploy fixture alive on malformed requests

An empty request body crashed the HTTP fixture outside its assertions. Return HTTP 400 for invalid JSON and exercise that request before the full validation and installation assertions.
2026-10-21 10:48:01 +07:00
Mohamed Boudra 461ea2ee9e test(e2e): hide the developer error toast instead of clicking it away (#7480)
* test(e2e): hide the developer error toast instead of clicking it away

The locator handler that dismissed #error-toast could match a toast that closed before its
click; dispatchEvent then waited for the gone button until the test timed out. Hiding the toast
with CSS keeps it off app actions with no race.

* test(e2e): desktop browser-tabs hides developer toasts on its attached page
2026-10-21 10:46:54 +07:00
23b05bbcc5 Keep archived agents out of discovery after history reads (#7532)
* fix(server): report archived agents correctly over MCP

An archived agent is resumed back into memory whenever something reads its history:
agent-loading passes `{ purpose: "history" }` to resumeAgentFromPersistence exactly when
`record.archivedAt` is set. `list_agents` then sees it as a live ManagedAgent, which
carries no archive timestamp, so AgentDirectory's archive predicate passes it and the
stored record's timestamp never reaches the result:

- `list_agents` returns the agent with `archivedAt: null`, because
  serializeSnapshotWithMetadata merges only the title from storage. A caller reconciling
  ownership from that list concludes an archived agent is still running.
- a default call (`includeArchived: false`) returns it anyway.

Storage stays the single owner of the timestamp. The MCP boundary merges the stored
record the same way the WebSocket path does in Session.enrichAgentPayload, reusing the
storage read it already makes for the title, and `archivedAt` becomes a projection option
beside `title`. `list_agents` filters archives after projection, where the timestamp is
known.

Copying the timestamp onto the managed agent was the other option and is worse:
unarchiveSnapshot clears only the stored record, so a managed copy would report an
unarchived agent as archived until it was reloaded.

The wire change is additive: `archivedAt` is already nullable and optional in the
protocol, and already populated on the stored path.

Fixes #4753

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): keep archived agents out of discovery after history reads

Apply archive filtering after stored metadata is projected in the shared directory. Exercise archive, history hydration, MCP discovery, WebSocket discovery and unarchive through a real daemon instead of mocked manager snapshots.

Co-authored-by: Thomas Van <minhthong@gmail.com>

---------

Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 04:54:43 +02:00
paseo-bot[bot]andThomas Van 1c62748084 Notify callers when detached agents finish (#7528)
* fix(server): notify the caller when a detached child finishes

`create_agent` with a detached relationship and `notifyOnFinish` arms a finish watcher
with `requireParentOwnership: true`. At fire time that guard checks that the child is
still labelled as the caller's, but resolveCreateAgentIntent strips the parent label from
a detached child on purpose, so the check can never pass and the caller never hears the
child it created finish.

The create path now requires parent ownership only for a child created with a parent.
The guard itself is unchanged: a parented child whose label later points elsewhere stays
silent, as agent-prompt.test.ts ("detaching a child ends its parent-owned finish
notification") requires. Only the create call knows the caller asked for a detached
child, which is why the fix lives there.

Tests drive the real finish watcher from createAgentCommand, with the finish published
as the manager publishes it: a detached child notifies its caller once, and a parented
child moved to another parent does not. Requiring ownership always fails the first;
never requiring it fails the second.

Fixes #6908

* test(server): verify detached completion through agent-scoped MCP

Exercise creation, stored parent labels, provider completion, and caller delivery
with the real daemon and injected provider clients. Cover detached and parented
creation and suppression after reparenting without replacing manager methods.

Co-authored-by: Thomas Van <minhthong@gmail.com>

---------

Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 03:54:02 +02:00
711 changed files with 44276 additions and 13162 deletions
+9 -1
View File
@@ -3,8 +3,8 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
@@ -417,6 +417,14 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Run Android module tests
run: npm run test:android --workspace=@getpaseo/app
playwright-1:
name: playwright (shard 1/8)
needs: changes
-1
View File
@@ -6,7 +6,6 @@ on:
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
# or how it is smoked still proves the real artifacts before merge.
pull_request:
branches: [main]
paths:
- "packages/desktop/**"
- "plugins/**"
+3238
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -41,11 +41,13 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
@@ -106,6 +108,8 @@ npm run format:check # Check formatting without writing
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Release branches
+7 -7
View File
@@ -59,8 +59,8 @@
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
</tr>
</table>
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
### 桌面 app(推荐)
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
如果要从手机连接,在 Settings 中扫描显示的二维码。
@@ -97,8 +97,8 @@ paseo
完整安装和配置见:
- [文档](https://paseo.sh/docs)
- [配置参考](https://paseo.sh/docs/configuration)
- [文档](https://paseo.sh/zh-CN/docs)
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
## CLI
@@ -116,7 +116,7 @@ paseo send abc123 "also add tests" # 发送后续任务
paseo --host workstation.local:6767 run "run the full test suite"
```
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
更多内容见[完整 CLI 参考](https://paseo.sh/zh-CN/docs/cli)。
## Skills
@@ -164,7 +164,7 @@ npm run typecheck
## 支持 Paseo
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
## 相关项目
+8 -1
View File
@@ -71,9 +71,16 @@ normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` for explicit reporting or no completion callback.
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
+65 -23
View File
@@ -61,39 +61,81 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
font size or another unhandled configuration can otherwise disable image selection
until the app process restarts. Rebuild the native app after changing this patch;
a Metro reload cannot update it. Remove the patch when the installed
`expo-modules-core` includes the fix.
Run its native regression tests from `packages/app/android` after prebuild:
```bash
./gradlew :expo-modules-core:testDebugUnitTest \
--tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
From repo root:
```bash
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clear # Remove generated Android project
npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
npm run dev:android # Build/install the debug app and start development
npm run dev:ios # Build/install the iOS development app
```
For a production-ID release APK that local Android profiling tools can attach to:
`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable
`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable
flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
watchdog recording does not send duplicate spans to that sink by default. Normal store/release
workflows leave diagnostics disabled.
The older `android`, `android:development`, and `ios` commands remain aliases for development.
`android:production` still builds and installs a release through Expo; use `build:apk` when you
need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
### Diagnostic logs without ADB
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
retained `.jsonl` files. No app screen, storage permission, or attached debugger is required.
The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation
replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall
can leave older Downloads files that the new installation cannot manage.
Each file identifies its build and session. Events include application-transport frame sizes and
message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree
render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents
are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when
already available, otherwise **UTF-16 code units** for text. These are
client transport observations, after relay decryption where applicable, not packet arrival times.
The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval).
A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration.
Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked;
`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
when JS resumes before the next native sampling tick. Process death leaves earlier files intact,
but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.
`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include
faster renders. Nested profiler timings overlap: don't add parent and child times. React's render
measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped`
means the bounded queue overflowed; missing events in that interval must not be treated as absence
of activity. Storage failure stops recording rather than blocking the app.
For an ordinary release APK with system trace markers only:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
Or from `packages/app`:
```bash
# Debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debug
# Release
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=release
# Clear generated Android project
rm -rf android
```
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
they do not produce Downloads files.
## Running on an emulator against a worktree daemon
+3
View File
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
Your code never leaves your machine. Paseo is local-first.
Daemon-owned outbound connections and routed API scope ownership are described in
[Connected hosts](connected-hosts.md).
## System overview
```
+89
View File
@@ -0,0 +1,89 @@
# Connected hosts
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
`registrationId` created before B is reachable.
## Ownership and authority
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
target description. Replacement inputs never come back through a read API. Keep this file private
when collecting diagnostics or backing up daemon state.
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
including a user's disabled state. Changed input requires an explicit update. Display labels and
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
Disabling retains the opt-out.
Each enabled registration owns one physical daemon link, shared by status monitoring and every
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
physical link principal, then intersects forwarded caller grants with that link's current authority.
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
A producer that registers a host through a routed API is qualified on B as
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
## Delegating agents
Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`,
`create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents`
accept `serverId`; omission selects the calling daemon. Remote creation requires a
destination workspace and explicit provider/model. Caller filesystem defaults apply only on the
calling daemon. Prompting a remote child acknowledges acceptance and uses the existing idle wait for
finish notifications. Remote agent lists have no caller-directory default; pass `cwd` to filter them.
The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses
the ordinary conversation on the destination's session. The runtime selects among that host's
connection candidates, including direct connections and routes discovered through another daemon.
Adding a direct connection to a discovered host adds another candidate. If no candidate connects,
the destination workspace keeps its existing disconnected or reconnecting behavior.
Finish notifications use the existing idle wait, including a pending initial run or an already idle
child. They name the server and agent. A daemon restart or route loss ends an outstanding notification
wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain
no additional turn attribution or delivery guarantee.
## Connections and recovery
The shared Node connector owns the target grammar and each connection attempt, including a fresh
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
registrations remain inspectable; a changed destination requires explicit identity replacement.
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
through the usual owned-subscription contract. The ordinary client queues new requests while connecting
within its existing deadline and rejects requests while disconnected. Connection loss clears old
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
before retrying a mutation.
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
existing frame limit.
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
frame size. Awaitable output completes when the physical send completes. An overflowing channel
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
subscription demand is rebuilt by its owning client on reconnect.
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
[CLI reference](../public-docs/cli.md#connected-hosts), and
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
+6 -1
View File
@@ -83,7 +83,12 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
Other tabs reveal the close control on hover. Compact layouts show no inline close on any tab,
since a narrow X beside the label is easy to hit by accident; the long-press menu closes it.
Compact tabs also keep their natural width and the rail scrolls as soon as they overflow, while
pointer layouts shrink tabs toward the clickable minimum first. A rail that scrolls never truncates.
The + menu opens compatible panels in the dock, including Agent and terminal profiles. Compact
layouts omit Diff from it because compact Changes shows each file's diff inline.
Web also supports dragging agents and terminals into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
+6 -4
View File
@@ -282,10 +282,12 @@ left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
Do this before adding any platform offset. The app draws edge-to-edge on Android, so
`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate
space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor:
React Native 0.86 stopped subtracting the status bar from window coordinates under
edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu
one status bar below its trigger.
## Gotcha 5 — The two-measurement flash
+1 -1
View File
@@ -1,6 +1,6 @@
# I18n
Paseo client UI translations live in `packages/app/src/i18n`.
Paseo client UI translations live in `packages/app/src/i18n`. Website translations are in [website-i18n.md](website-i18n.md).
## Supported Locales
+4
View File
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
see [Connected hosts](connected-hosts.md#ownership-and-authority).
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
+33 -9
View File
@@ -86,6 +86,12 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
never deletes it. The global `pluginsEnabled` switch remains available.
## Client translations
Plugin runtime translations follow the connected app, so never send a daemon locale through RPCs or add plugin resources to the app catalog. The installation owns the translation engine; native portal bridges must carry it alongside RPC and client-state contexts. Keep host label resolution at presentation time so language changes preserve the installation lifetime.
The author contract is in [runtime translations](../public-docs/plugins/reference.md#runtime-translations).
## Built-in plugins
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
@@ -240,7 +246,7 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
@@ -257,14 +263,14 @@ Classify every SDK export before adding it. All client entry points and implemen
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
| Entry | Owns | May depend on |
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
Server code imports shared helpers from the root and server capabilities from `/server`. Client
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
@@ -556,6 +562,24 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the
same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
make that panel faster instead. Source highlights a large file on native in idle-time slices,
the lines around the opening line first.
The handle checks every write against the version it last read or wrote, so two panels or a panel
and an agent cannot silently overwrite each other. The daemon reports the file's kind (text,
image, binary) with its stat from a bounded sniff, which is how Source claims text files without a
read. See the [public file panel reference](../public-docs/plugins/reference.md#file-panels) for
the author API, selection, `DocumentFrame`, and the version requirement. The examples live in
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
+12 -3
View File
@@ -62,9 +62,10 @@ release. This applies to both beta and stable releases.
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
and GitHub permits only squash merges. Enable checks and required-check protection
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
**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.
@@ -533,6 +534,14 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
## Changelog translations
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
## Changelog ownership
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
+51
View File
@@ -0,0 +1,51 @@
# Website translations
paseo.sh is served in English and Simplified Chinese (`zh-CN`). This doc covers the website in `packages/website`. App UI translations are in [i18n.md](i18n.md).
## URLs own the language
- English pages keep their unprefixed paths. Every other language lives under its tag: `/docs/cli` and `/zh-CN/docs/cli` are the same page. `/en/...` is not a page.
- The server renders the language in the URL. There is no browser-language detection or redirect, so a shared link always opens in the language it was shared in.
- The language selector moves to the same page in the other language and keeps the query and hash. Every internal link on a page carries the page's language, so the choice sticks while the reader navigates. An explicit English URL always renders English.
- The choice is not stored. A stored choice could only take effect by redirecting unprefixed URLs, which are the English pages. First-party links into the site carry the reader's language instead: `README.zh-CN.md` links to `/zh-CN/...`.
- Routes live under `src/routes/{-$locale}/`. Build internal links with `localizePath` or `useLocalizePath` from `~/i18n`. Links inside Markdown are localized by `DocsMarkdown`. Files, `.md` sources, `llms.txt` and API endpoints keep one path for every language (`isPagePath`).
- Give each page's `head` the language-neutral path through `pageMeta` (`src/meta.ts`). It writes the self canonical, the `hreflang` alternates with English as `x-default`, and `og:locale`. The sitemap in `vite.config.ts` lists the same alternates.
## Where copy lives
| Content | English | Chinese |
| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
| Interface copy | `src/i18n/resources/en.json` | `src/i18n/resources/zh-CN.json` |
| Docs | `public-docs/<page>.md` | `public-docs/<page>.zh-CN.md` |
| Blog posts | `posts/<slug>.md` | `posts/<slug>.zh-CN.md` |
| Comparison pages | `src/content/alternatives/<slug>.md` | `src/content/alternatives/<slug>.zh-CN.md` |
| Legal pages | `legal/<page>.md` | `legal/<page>.zh-CN.md` |
| Changelog | `CHANGELOG.md` | `CHANGELOG.zh-CN.md`, see [release.md](release.md#changelog-translations) |
| Plugin listings | the plugin's `paseo-plugin.json` and `OVERVIEW.md` | `translations` and `OVERVIEW.zh-CN.md`, see `public-docs/plugins/publishing.md` |
`en.json` types the keys, so typecheck fails when `zh-CN.json` misses one. A translation file is grouped with its English original by name (`src/markdown-content.ts`). A translation without an English original fails the build.
Every first-party page has a translation. The changelog and plugin listings are the exceptions: a release or listing without one renders whole in English under a notice. Pages never mix languages paragraph by paragraph. Navigation order, categories and slugs always come from the English file's frontmatter.
## Editing a translated doc
Edit the English file and its `.zh-CN.md` in the same PR, and add the `.zh-CN.md` with any new page. Code blocks, commands, flags, config keys and links are copied verbatim, so code-only changes are a copy. Never delete a translation to get around an edit, and never leave one that describes behavior the English no longer has.
Translated headings pin the English anchor with a trailing `{#id}`, so `/zh-CN/docs/cli#running-agents` lands on the same section as the English link:
```md
## 运行 Agent {#running-agents}
:::example[回复 Slack 提及 {#answer-a-slack-mention}]
```
When you rename an English heading, change the `{#id}` in the translation to the new slug. `src/content-translations.test.ts` fails when a docs, blog, comparison or legal page has no translation, and when a translation's headings differ from the English in count, level, order or anchor. It cannot tell whether the prose under a heading is current. The `.md` source endpoints serve files verbatim, so `{#id}` shows there as written.
## Chinese style
- Keep code, commands, file paths, IDs, brand and product names, and command output in English.
- Keep these terms in English: Agent, worktree, daemon, Provider, PR, diff, Composer, Hub, workflow, heartbeat, slug.
- Translate these: workspace 工作区, host 主机, relay 中继, plugin 插件, skill 技能, review 审查, schedule 定时任务, orchestration 编排, configuration 配置, prompt 提示词, subagent 子 Agent.
- Quote app UI labels with the app's Chinese wording from `packages/app/src/i18n/resources/zh-CN.ts`, such as **设置 → 插件**. Labels the app has not translated stay in English, as do labels in Hub, GitHub, Slack and Discord. Add the English label in parentheses only where readers search for it, such as an error message.
- Put a space between Chinese and Latin text or numbers: `启动 Agent`, `共 3 个`.
- Bold that ends in full-width punctuation does not render when Chinese text follows it directly: `**注意:**这里` shows the asterisks. Move the punctuation outside the bold (`**注意**:这里`).
+1 -1
View File
@@ -1 +1 @@
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
sha256-La6xekVRQ9F6UulA6OHX5SzaXv6L0qZ2KpGkRScFd8Q=
+4
View File
@@ -38178,6 +38178,7 @@
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -42116,6 +42117,7 @@
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
@@ -43338,9 +43340,11 @@
"@tanstack/react-start": "^1.168.26",
"framer-motion": "^12.35.2",
"github-slugger": "^2.0.0",
"i18next": "^26.3.0",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.3",
+6 -3
View File
@@ -41,12 +41,16 @@
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"dev:android": "npm run dev:android --workspace=@getpaseo/app",
"dev:ios": "npm run dev:ios --workspace=@getpaseo/app",
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:apk": "node scripts/build-apk.mjs",
"build:apk:arm64": "node scripts/build-apk.mjs arm64",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
@@ -80,15 +84,14 @@
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android": "npm run dev:android",
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
"ios": "npm run ios --workspace=@getpaseo/app",
"ios": "npm run dev:ios",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
+5 -1
View File
@@ -10,6 +10,7 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
@@ -181,7 +182,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild ? [withAndroidProfileable] : []),
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -191,6 +192,9 @@ export default {
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
watchdogBuild: isWatchdogBuild,
diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION,
diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
+144 -6
View File
@@ -262,24 +262,74 @@ test.afterEach(async () => {
}
});
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
test("Changes opens All changes for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace, 90_000);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
test("All changes combines committed and pending edits and preserves an unfinished review", async ({
page,
context,
}) => {
const workspace = await createWorkspaceWithNetDiff();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openWorkspaceChangesSurface(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = tree.getByRole("button", { name: "Diff mode", exact: true });
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]);
await expectRenderedAddedLine(page, "committed");
await test.step("an unfinished review survives the first dirty edit", async () => {
await startReviewOnFirstChangedLine(page);
await page
.getByRole("textbox", { name: "Review comment" })
.fill("Unfinished review stays here");
await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(
"Unfinished review stays here",
);
await cancelInlineReview(page);
});
await test.step("pending edits form a live net patch over the committed edit", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n");
await expectRenderedAddedLine(page, "working");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1");
});
await test.step("committing the same contents preserves the rendered comparison", async () => {
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath });
await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", {
timeout: 30_000,
});
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expectRenderedAddedLine(page, "working");
});
await test.step("undoing to the comparison base removes the net change", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n");
await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
});
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await expect(mode).toContainText("Uncommitted");
await expect(mode).toContainText("All changes");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
@@ -294,7 +344,23 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
});
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Committed");
await selectChangesComparison(page, "Uncommitted");
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath });
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n");
await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
});
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
@@ -633,6 +699,7 @@ test("changes context menus expose folder revert and restore a file after confir
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
@@ -680,6 +747,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
@@ -710,6 +778,7 @@ test("discarding an untracked file removes it from the working tree", async ({ p
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
@@ -741,6 +810,7 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
@@ -832,6 +902,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
// The shell survives the breakpoint, so the wide Explorer can still be on screen right after the
// resize. Check for the compact tab only once the shell reports the compact layout.
await expect(page.getByTestId("compact-shell")).toBeVisible();
const compactChangesTab = page
.getByTestId("workspace-tab-changes_tree")
@@ -889,6 +962,8 @@ test("compact Explorer renders inline hunks with a saved tree-only desktop prefe
{ key: CHANGES_PREFERENCES_KEY },
);
const explorer = await openCompactChanges(page, workspace);
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
@@ -1522,6 +1597,64 @@ async function createWorkspaceWithMountedTabDiff(
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithNetDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-net-", {
files: [{ path: "a-tracked.ts", content: "base\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close();
await repo.cleanup();
},
});
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n");
await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n");
execFileSync("git", ["add", "--all"], { cwd: repo.path });
execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path });
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function expectNetComparisonFiles(page: Page, files: string[]): Promise<void> {
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
files,
);
await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length);
for (const file of files) {
await expect(diffHeaderForPath(panel, file)).toBeVisible();
}
}
async function expectRenderedAddedLine(page: Page, contents: string): Promise<void> {
await changesTree(page).getByText("a-tracked.ts", { exact: true }).click();
await expect(async () => {
await page.keyboard.press("Escape");
await page.evaluate(() => navigator.clipboard.writeText(""));
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const bounds = await body.boundingBox();
if (!bounds) throw new Error("Expanded diff body has no bounds");
const fontSize = await canvas.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
// Replacement: hunk header, removed line, added line.
await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, {
button: "right",
});
const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true });
await expect(copyLine).toBeEnabled({ timeout: 1000 });
await copyLine.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 })
.toBe(contents);
}).toPass({ timeout: 15_000 });
}
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
@@ -1844,9 +1977,14 @@ async function changeCodeTypographyFromSettings(
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
const sizeField = page.getByLabel("Code font size");
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
await page.getByLabel("Code font family").fill(typography.fontFamily);
await page.getByLabel("Code font family").press("Enter");
await page.getByLabel("Code font size").fill(String(typography.fontSize));
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
await sizeField.fill(String(typography.fontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
await expect(page.getByLabel("Code font size")).toHaveValue(String(typography.fontSize));
@@ -0,0 +1,58 @@
import { metroTest as test, expect } from "../support/fixtures";
import { hideDeveloperToasts } from "../support/developer-ui.mjs";
import type { Page } from "@playwright/test";
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
await page.evaluate((useShadow) => {
const toast = document.createElement("div");
toast.id = "error-toast";
document.body.append(toast);
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
const button = document.createElement("button");
button.style.cssText = "width:32px;height:32px";
button.addEventListener("click", () => button.remove());
root.append(button);
}, shadow);
}
async function openHiddenDeveloperToast(page: Page) {
await page.setContent("<button>Continue</button>");
await showTransientDeveloperToast(page, false);
await hideDeveloperToasts(page);
}
async function navigateWithHiddenDeveloperToasts(page: Page) {
await hideDeveloperToasts(page);
await page.goto("data:text/html,<button>Continue</button>");
await showTransientDeveloperToast(page, true);
}
async function inspectLayoutWithHiddenToast(page: Page) {
const action = page.getByRole("button", { name: "Continue", exact: true });
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
for (const box of boxes) expect(box).not.toBeNull();
await expect(page.locator("#error-toast")).toBeHidden();
await action.click();
}
test("a hidden developer toast does not stall concurrent layout checks", async ({ page }) => {
test.setTimeout(10000);
await openHiddenDeveloperToast(page);
await inspectLayoutWithHiddenToast(page);
});
test("developer toast hiding survives navigation and Expo shadow-root updates", async ({
page,
}) => {
test.setTimeout(10000);
await navigateWithHiddenDeveloperToasts(page);
await inspectLayoutWithHiddenToast(page);
await page.evaluate(() => {
const root = document.getElementById("error-toast")!.shadowRoot!;
const replacement = document.createElement("button");
replacement.style.cssText = "width:32px;height:32px";
replacement.addEventListener("click", () => replacement.remove());
root.append(replacement);
});
await inspectLayoutWithHiddenToast(page);
});
@@ -56,45 +56,12 @@ async function replaceEditorText(page: Page, content: string): Promise<void> {
await editor.type(content);
}
async function replaceFileAfterDeletionWasObserved(input: {
page: Page;
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
content: string;
}): Promise<void> {
const { page, gate, filePath, relativePath, content } = input;
// Recreate the observed race without faking file state: the daemon publishes the
// deletion, then a real read succeeds after the replacement while its ready event waits.
await gate.waitForFileSubscription(relativePath);
gate.holdFileReads(relativePath);
await unlink(filePath);
await gate.waitForHeldFileRead();
await expect(fileCallout(page)).toHaveCount(0);
gate.releaseHeldFileRead();
await expectOnlyFileCallout(page, "File deleted on disk");
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(filePath, content, "utf8");
await gate.waitForHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
}
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
await expect(fileCallout(page)).toHaveCount(1);
await expect(fileCallout(page)).toContainText(title);
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
}
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
}
async function expectDeletedFileNotice(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "File deleted on disk");
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
@@ -140,7 +107,7 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const preserved = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(visibleEditor(page)).toContainText("const preserved = true;");
await recordFileCallouts(page);
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
@@ -189,162 +156,79 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const before = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(visibleEditor(page)).toContainText("const before = true;");
await recordFileCallouts(page);
gate.holdFileReads(relativePath);
gate.holdNextReadyFileUpdate(relativePath);
await replaceEditorText(page, "const saved = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
await gate.waitForHeldFileRead();
await gate.waitForHeldReadyFileUpdate();
gate.releaseHeldReadyFileUpdate();
await expect(visibleEditor(page)).toContainText("const saved = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
gate.releaseHeldFileRead();
});
test("a clean file replaced on disk offers one working reload action", async ({
test("a viewed file deleted and written back shows the new file", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-clean-replacement-",
relativePath: "inventory.md",
content: "# Before\n",
});
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await replaceFileAfterDeletionWasObserved({
page,
gate,
filePath,
relativePath: "inventory.md",
content: "# After\n",
});
gate.releaseHeldReadyFileUpdate();
await expectCleanReplacementCanReload(page);
await unlink(filePath);
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
await writeFile(filePath, "# After\n", "utf8");
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
});
test("a deleted file shows one explanatory notice and no resolution actions", async ({
test("a deleted file keeps the open copy with one notice and no resolution actions", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-deleted-",
relativePath: "deleted.md",
content: "# Present\n",
relativePath: "deleted.ts",
content: "const present = true;\n",
});
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
await expect(visibleEditor(page)).toContainText("const present = true;");
await recordFileCallouts(page);
await unlink(filePath);
await expectDeletedFileNotice(page);
await expectFileCalloutWasRendered(page, "File deleted on disk");
await expect(visibleEditor(page)).toContainText("const present = true;");
});
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-retry-",
relativePath,
content: "# Before\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "# After\n", "utf8");
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
gate.releaseHeldFileRead();
await expectCleanReplacementCanReload(page);
gate.releaseHeldReadyFileUpdate();
});
test("retry clears a transient error when the file is unchanged", async ({
test("a path that stops being a file keeps the open copy and follows the file back", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "unchanged.md";
const relativePath = "retry.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-unchanged-",
prefix: "file-check-error-",
relativePath,
content: "# Unchanged\n",
content: "const before = true;\n",
});
const parkedPath = `${filePath}.parked`;
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
await expect(visibleEditor(page)).toContainText("const before = true;");
await rename(filePath, parkedPath);
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await gate.waitForFileUpdate(relativePath, "error");
await expect(visibleEditor(page)).toContainText("const before = true;");
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await rename(parkedPath, filePath);
await gate.waitForHeldReadyFileUpdate();
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await writeFile(filePath, "const after = true;\n", "utf8");
await expect(visibleEditor(page)).toContainText("const after = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a read-only file error preserves its message and offers retry", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "artifact.bin";
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-read-only-retry-",
relativePath,
content: binaryContent,
});
await gate.waitForFileSubscription(relativePath);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toBeVisible();
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, binaryContent);
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await filePane(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldFileRead();
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a changed file with local edits offers overwrite and a working reload", async ({
+18 -20
View File
@@ -55,6 +55,10 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
await expectFileTabOpen(page, filename);
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
function htmlPreview(page: Page) {
return {
host: page.getByTestId("file-html-preview"),
@@ -62,11 +66,9 @@ function htmlPreview(page: Page) {
};
}
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
exact: true,
});
/** Switches the file tab to one of its panels from the bar's switcher. */
async function selectFilePanel(page: Page, title: string): Promise<void> {
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
@@ -128,7 +130,7 @@ test.describe("CodeMirror workspace file editing", () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
@@ -317,11 +319,7 @@ test.describe("CodeMirror workspace file editing", () => {
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
await writeFile(path.join(session.cwd, "too-large.txt"), "x".repeat(51 * 1024 * 1024), "utf8");
try {
await openAgentRoute(page, session);
@@ -484,9 +482,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = page.getByTestId("file-preview-mode");
const modeControl = filePanelSwitcher(page);
await expect(modeControl).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -548,7 +546,7 @@ test.describe("CodeMirror workspace file editing", () => {
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
@@ -680,7 +678,7 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
test("refreshes Markdown and images while switching between Markdown and Source", async ({
page,
withWorkspace,
}) => {
@@ -695,15 +693,15 @@ test.describe("CodeMirror workspace file editing", () => {
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFileView(page, "Preview");
await selectFilePanel(page, "Markdown");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
@@ -786,10 +784,10 @@ test.describe("CodeMirror workspace file editing", () => {
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFileView(page, "Preview");
await selectFilePanel(page, "HTML");
await expect(preview.host).toBeVisible();
});
@@ -14,8 +14,9 @@ async function openMarkdownPreview(page: Page) {
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Markdown", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
@@ -0,0 +1,47 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
async function openNestedGuide(page: Page): Promise<void> {
await openFileExplorer(page);
await expandFolder(page, "docs");
await openFileFromExplorer(page, "guide.md");
}
async function followDetailsLink(page: Page): Promise<void> {
await page
.getByTestId("markdown-preview-frame")
.getByRole("link", { name: "Details", exact: true })
.click();
}
test("Markdown preview resolves a relative link from the document directory", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "markdown-relative-links-" });
await mkdir(path.join(workspace.repoPath, "docs/guide"), { recursive: true });
await writeFile(
path.join(workspace.repoPath, "docs/guide.md"),
"# Guide\n\n[Details](guide/details.md)\n",
);
await writeFile(
path.join(workspace.repoPath, "docs/guide/details.md"),
"# Nested details destination\n",
);
await workspace.navigateTo();
await openNestedGuide(page);
await followDetailsLink(page);
await expect(
page
.getByTestId("markdown-preview-frame")
.getByText("Nested details destination", { exact: true }),
).toBeVisible();
});
@@ -95,7 +95,7 @@ test.describe("New workspace entry points", () => {
}
});
test("the New Workspace screen hides the host selector when there is only one host", async ({
test("the New Workspace screen shows the host selector with Add host when there is only one host", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
@@ -120,7 +120,12 @@ test.describe("New workspace entry points", () => {
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
} finally {
await seeded.cleanup();
}
@@ -1,3 +1,4 @@
import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations";
import { test } from "../support/fixtures";
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
@@ -13,3 +14,10 @@ test("plugin header and composer buttons share actions, menus, content, and upda
await buttons.hideAndUnloadOpenButtons();
});
});
test("plugin tooltips and menu labels follow app language with a panel draft open", async ({
page,
}, info) => {
test.setTimeout(180_000);
await verifyPluginToolTranslations(page, info);
});
@@ -0,0 +1,279 @@
import { execFileSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Frame, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
// Full Chromium includes the PDF viewer; the default headless shell does not.
test.use({ channel: "chromium" });
function samplePdf() {
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
const offsets = objects.map((object, index) => {
const offset = Buffer.byteLength(pdf);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
return offset;
});
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return pdf;
}
async function openFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
const WIDE = { width: 1280, height: 900 };
const COMPACT = { width: 390, height: 844 };
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
async function captureWideAndCompact(
page: Page,
testInfo: TestInfo,
name: string,
ready: () => Promise<void>,
) {
await capture(page, testInfo, `${name}-wide`);
// The shell survives the breakpoint, so the previous layout's copy can still be on screen right
// after a resize. Check `ready` only once the shell reports the new layout.
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("compact-shell")).toBeVisible();
await ready();
await capture(page, testInfo, `${name}-compact`);
await page.setViewportSize(WIDE);
await expect(page.getByTestId("wide-shell")).toBeVisible();
await ready();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
});
}
async function chooseRichMarkdown(page: Page) {
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
await page.getByRole("button", { name: "Use", exact: true }).click();
}
async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
async function selectPanel(page: Page, title: string) {
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
}
async function expectPanels(page: Page, titles: string[]) {
for (const title of titles) {
await expect(
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
).toBeInViewport({ ratio: 1 });
}
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
async function pdfViewer(page: Page): Promise<Frame> {
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
const find = () =>
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
await expect.poll(() => find() !== null).toBe(true);
return find()!;
}
async function expectPdfViewerReady(page: Page) {
// A narrow viewer collapses its toolbar to Print and More actions.
const viewer = await pdfViewer(page);
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
}
async function expectMarkdownEditor(page: Page, text: string) {
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const pdf = await copyPluginExample("pdf-panel");
const markdown = await copyPluginExample("markdown-panel");
execFileSync(
process.platform === "win32" ? "npm.cmd" : "npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: markdown.directory, stdio: "pipe" },
);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-examples-",
repo: {
files: [
{
path: "notes.md",
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
},
{ path: "second.md", content: "# Remembered panel\n" },
{ path: "report.pdf", content: samplePdf() },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pdf.directory);
await client.installDirectoryPlugin(markdown.directory);
await enableVimKeybindings(page);
await page.setViewportSize(WIDE);
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFile(page, "report.pdf");
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
"sandbox",
);
const documentUrl = await page
.getByTitle("Document preview", { exact: true })
.getAttribute("src");
expect(
await page.evaluate(
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
documentUrl,
),
).toBe("application/pdf");
await expectPdfViewerReady(page);
await expect(
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
).toHaveValue("1");
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
await expectPdfViewerReady(page);
});
await openFile(page, "notes.md");
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
// both pickers are mounted for a moment after a resize.
await expect(page.getByRole("button", { name: "Use", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
});
await chooseRichMarkdown(page);
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Markdown panel example",
);
gate.holdNextClientRequest("fs.file.write.request");
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n");
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await capture(page, testInfo, "markdown-example-dirty-wide");
gate.releaseHeldClientRequest();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
await selectPanel(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-source-status").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
await page.setViewportSize(COMPACT);
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
await filePanelSwitcher(page).click();
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
await expect(filePanelSwitcher(page)).toHaveText("Source");
await source.locator(".cm-line").nth(2).click();
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
await capture(page, testInfo, "markdown-example-source-compact");
await page.setViewportSize(WIDE);
await selectPanel(page, "Rich Markdown");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
);
await replaceMarkdown(page, "# Local edit\n");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible();
await capture(page, testInfo, "markdown-example-conflict-wide");
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Local edit\n");
await openFile(page, "second.md");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Remembered panel",
);
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await workspace.cleanup();
await pdf.cleanup();
await markdown.cleanup();
}
});
@@ -0,0 +1,156 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
expectBuiltinMarkdown,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
async function choosePanel(page: Page, option: string, options: { remember: boolean }) {
await page.getByRole("radio", { name: option, exact: true }).click();
if (options.remember) {
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
}
await page.getByRole("button", { name: "Use", exact: true }).click();
await expect(filePanelPicker(page)).toBeHidden();
}
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function toggleAlwaysUseFromFileBar(page: Page) {
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
async function expectSwitcherPanels(page: Page, titles: string[]) {
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "file-panel-builtins-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
await expectSwitcherPanels(page, ["Markdown", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-wide");
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
} finally {
await workspace.cleanup();
}
});
test("asks once which panel opens Markdown and remembers it when told to", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-picker-",
repo: {
files: ["first", "second", "third", "fourth"].map((name) => ({
path: `${name}.md`,
content: `# ${name} heading\n`,
})),
},
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
// First open: the built-in renders under the picker.
await openMarkdown(page, "first.md");
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
// Don't ask me again: the plugin renders and later opens use it silently.
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true });
await expectPluginMarkdown(page, "first.md");
expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID });
await openMarkdown(page, "second.md");
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
// Forgetting from the file bar keeps the tab on the plugin.
await toggleAlwaysUseFromFileBar(page);
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
// An unremembered choice applies to this tab only; the next open asks again.
await openMarkdown(page, "third.md");
await expect(filePanelPicker(page)).toBeVisible();
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false });
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
// "Open with…" switches the open tab back to the built-in in place.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
await expect(filePanelPicker(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(filePanelPicker(page)).toBeHidden();
await expectBuiltinMarkdown(page, "fourth heading");
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
await capture(page, testInfo, "file-panel-picker-compact");
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
@@ -0,0 +1,150 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
SOURCE_OPTION,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsSection } from "../support/helpers/settings";
const ASK_EVERY_TIME = "Ask every time";
function markdownRow(page: Page) {
return page.getByTestId("file-panel-settings-row-ext:md");
}
function markdownDropdown(page: Page, value: string) {
return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true });
}
function askAgain(page: Page) {
return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true });
}
async function openFilesSettings(page: Page) {
await openSettings(page);
await openSettingsSection(page, "files");
await expect(page.getByTestId("file-panel-settings")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
async function expectMenuOpen(page: Page) {
await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1");
}
test("Settings > Files chooses which panel opens .md and asks again on request", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-settings-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFilesSettings(page);
// Nothing remembered: the row asks every time and has nothing to forget.
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeDisabled();
await capture(page, testInfo, "file-panel-settings-wide");
await markdownDropdown(page, ASK_EVERY_TIME).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-wide-menu");
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible();
await expect
.poll(() => storedFilePanelAssociations(page))
.toEqual({
"ext:md": MARKDOWN_PANEL_ID,
});
await capture(page, testInfo, "file-panel-settings-wide-chosen");
// The next open uses the plugin without asking.
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectPluginMarkdown(page, "notes.md");
await expect(filePanelPicker(page)).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/settings/files");
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
await capture(page, testInfo, "file-panel-settings-compact");
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-compact-menu");
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
// Ask again forgets the choice; the next open asks.
await askAgain(page).click();
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible();
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
test("Settings > Files explains where file panels come from when no plugin adds one", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/settings/files");
const empty = page.getByTestId("file-panel-settings-empty");
await expect(empty).toHaveText("File panels appear here when a plugin adds one", {
timeout: 30_000,
});
await capture(page, testInfo, "file-panel-settings-empty-wide");
await page.setViewportSize({ width: 390, height: 844 });
await expect(empty).toBeVisible();
await capture(page, testInfo, "file-panel-settings-empty-compact");
});
test("Settings > Files offers Source in place of a panel no host has, and forgets it", async ({
page,
}) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }),
);
});
await page.goto("/settings/files");
const row = page.getByTestId("file-panel-settings-row-ext:pdf");
const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" });
await expect(dropdown).toBeEnabled({ timeout: 30_000 });
await dropdown.click();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click();
await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible();
});
@@ -0,0 +1,232 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
const PLUGIN_ID = "file-panel-e2e";
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
const SOURCE = `import React, { useEffect, useState } from "react";
import { Text, View, Pressable } from "react-native";
import {
PluginFileConflictError,
useFileBytes,
useFileUrl,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin/client";
import { useQueryClient } from "@tanstack/react-query";
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
function Pdf({ file, workspaceId }) {
usePaseo();
useQueryClient();
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
const bytes = useFileBytes(file);
const url = useFileUrl(file);
const [urlBytes, setUrlBytes] = useState(-1);
const href = url.status === "ready" ? url.url : null;
useEffect(() => {
if (href) fetch(href).then(response => response.arrayBuffer()).then(body => setUrlBytes(body.byteLength));
}, [href]);
if (bytes.status !== "ready" || !href) return <Loading />;
return <View style={{ flex: 1 }}>
<Text>PDF plugin: {file.name}</Text>
<Text>Path {file.path}</Text>
<Text>Bytes {bytes.bytes.length}; URL bytes {urlBytes}</Text>
<Text>Workspace {workspace?.id}</Text>
<DocumentFrame url={href} testID="plugin-pdf-frame" />
</View>;
}
// Reads once and writes on demand, so a change on disk since that read rejects the write.
function Markdown({ file, tab }) {
const [text, setText] = useState(null);
const [status, setStatus] = useState("");
useEffect(() => { file.readText().then(setText); }, [file]);
const save = (next) => {
tab.setModified(true);
file.writeText(next).then(
() => { tab.setModified(false); setText(next); },
(error) => setStatus(error instanceof PluginFileConflictError ? "Write rejected: changed on disk" : String(error)),
);
};
if (text === null) return <Loading />;
return <View>
<Text>Plugin Markdown: {text}</Text>
<Pressable accessibilityRole="button" onPress={() => save("# Saved by plugin\\n")}>
<Text>Edit Markdown through plugin</Text>
</Pressable>
<Text>{status}</Text>
</View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
files: { extensions: ["pdf", "txt"] }, Component: Pdf });
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
files: { extensions: ["md"] }, Component: Markdown });
return () => {};
}`;
async function openWorkspaceFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
async function associateMarkdownWithPlugin(page: Page) {
await page.evaluate(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" };
localStorage.setItem(key, JSON.stringify(settings));
});
await page.reload();
}
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
async function selectSource(page: Page) {
await page
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
}
test("directory plugins render PDF bytes and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-"));
const workspace = await seedWorkspace({
repoPrefix: "file-panels-",
repo: {
files: [
{ path: "notes.md", content: "# Built-in Markdown\n" },
{ path: "bytes.txt", content: "Bytes panel text document" },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openWorkspaceFile(page, "report.pdf");
await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible();
await expect(
page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }),
).toBeVisible();
await expect(
page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(
page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }),
).toBeVisible();
const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png");
await page.screenshot({ path: pdfScreenshot });
await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" });
// A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin.
await openWorkspaceFile(page, "bytes.txt");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden();
await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"sandbox",
"",
);
await expect(
page
.frameLocator('[title="Document preview"]')
.getByText("Bytes panel text document", { exact: true }),
).toBeVisible();
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await expect(
page.getByRole("heading", { name: "Built-in Markdown", exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
await associateMarkdownWithPlugin(page);
await expect(
page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }),
).toBeVisible();
gate.holdNextClientRequest("fs.file.write.request");
await editMarkdownThroughPlugin(page);
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
gate.releaseHeldClientRequest();
await expect(
page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }),
).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Saved by plugin\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
// The disk changed since the plugin's last read or write: the write is rejected, not applied.
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Changed outside\n");
await editMarkdownThroughPlugin(page);
await expect(page.getByText("Write rejected: changed on disk", { exact: true })).toBeVisible();
expect(await readFile(path.join(workspace.repoPath, "notes.md"), "utf8")).toBe(
"# Changed outside\n",
);
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
let closePrompt = "";
page.once("dialog", async (dialog) => {
closePrompt = dialog.message();
await dialog.dismiss();
});
await page
.getByTestId("workspace-tab-file_notes.md")
.filter({ visible: true })
.first()
.click({ button: "right" });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect.poll(() => closePrompt).toContain("discard");
await selectSource(page);
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Changed outside");
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
await page.screenshot({ path: markdownScreenshot });
await testInfo.attach("plugin-markdown-source-web", {
path: markdownScreenshot,
contentType: "image/png",
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await workspace.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
@@ -1,3 +1,4 @@
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -9,7 +10,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -26,6 +27,9 @@ function ModalBody({ onSaved }) {
return <View>
<Text>Plugin modal contexts ready</Text>
<Markdown>{"## Plugin report"}</Markdown>
<Markdown>{"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"}</Markdown>
<Markdown>{""}</Markdown>
<Pressable accessibilityRole="button" onPress={save}>
<Text>Save issue</Text>
</Pressable>
@@ -91,10 +95,20 @@ async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
async function expectPluginMarkdown(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible();
await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible();
await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute(
"href",
"https://paseo.sh/docs/plugins",
);
}
async function expectCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function closeCenteredPluginDialog(page: Page): Promise<void> {
@@ -106,6 +120,7 @@ async function closeCenteredPluginDialog(page: Page): Promise<void> {
async function expectCompactPluginSheet(page: Page): Promise<void> {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function savePluginIssue(page: Page): Promise<void> {
@@ -166,3 +181,10 @@ async function playPluginAudio(page: Page, button: string, result: string) {
await page.getByRole("button", { name: button, exact: true }).click();
await expect(page.getByText(result, { exact: true })).toBeVisible();
}
test("plugin content and host labels follow app language without losing an open draft", async ({
page,
}, info) => {
test.setTimeout(180_000);
await verifyPluginTranslations(page, info);
});
@@ -1,6 +1,7 @@
import { test } from "../support/fixtures";
import {
withTimelinePlugin,
viewInlineThinkingExample,
requestPluginTimeline,
interactWithStreamingCard,
expectWholeCompletedCard,
@@ -33,3 +34,9 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i
await expectBothConsecutiveTools(page);
});
});
test("inline-thinking example renders a streamed thought through host UI", async ({
page,
}, info) => {
await viewInlineThinkingExample(page, info);
});
@@ -36,7 +36,7 @@ function pluginClientSource(input: {
}): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { Icon } from "@getpaseo/plugin/client/ui";
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { recordComposerOpen } from "./shared/rpc";
@@ -1,4 +1,5 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
@@ -31,6 +32,28 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
async function showDeveloperToast(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById("error-toast")?.remove();
const toast = document.createElement("div");
toast.id = "error-toast";
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
// Expo renders its unnamed dismiss button in an open shadow root.
const button = document.createElement("button");
button.style.cssText = "width:24px;height:24px";
button.addEventListener("click", () => toast.remove());
toast.attachShadow({ mode: "open" }).appendChild(button);
document.body.appendChild(toast);
});
}
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
await gotoAppShell(page);
await showDeveloperToast(page);
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
await expect(page.locator("#error-toast")).toBeHidden();
});
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -1,10 +1,19 @@
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { submitMessage } from "../support/helpers/composer";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
import {
delegateFleetThroughMcp,
expectManagedSubagentArchived,
openSubagentsTrack,
seedParentWithSubagent,
} from "../support/helpers/subagents";
test.use({ e2eInjectPaseoTools: true });
const SETTINGS_KEY = "@paseo:app-settings";
@@ -147,3 +156,189 @@ test("a preferred chat file link still opens beside its right-pane agent", async
await workspace.cleanup();
}
});
test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({
page,
}) => {
const fleet = await delegateFleetThroughMcp();
let appSockets = 0;
let routeOpens = 0;
const activeDaemonSockets = new Set<unknown>();
const observationRequests: Record<string, number> = {};
page.on("websocket", (socket) => {
appSockets++;
if (new URL(socket.url()).pathname === "/ws") {
activeDaemonSockets.add(socket);
socket.on("close", () => activeDaemonSockets.delete(socket));
}
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string" || !payload.startsWith("{")) return;
if (String(payload).includes('"hosts.route.open.request"')) routeOpens++;
const outer = JSON.parse(String(payload));
const request = outer.type === "session" ? outer.message : null;
if (
request?.type &&
(request.subscribe !== undefined ||
request.observe === true ||
request.type.endsWith("set_subscription.request"))
)
observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1;
});
});
try {
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText(
"MCP child on A",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"MCP child on B",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"Fleet B",
);
await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click();
await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible();
await submitMessage(page, "LOCAL_UI_TARGET");
await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull();
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
await expect(page).toHaveURL((url) =>
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`),
);
await submitMessage(page, "REMOTE_UI_TARGET");
await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
await fleet.expectCompletion(fleet.local);
await fleet.expectCompletion(fleet.remote);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on A/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on B/ }),
).toBeVisible();
const owners = await page.evaluate(() => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
getHosts(): Array<{ serverId: string }>;
getSnapshot(
serverId: string,
): { connectionStatus: string; clientGeneration: number } | null;
};
return runtime.getHosts().map((host) => {
const snapshot = runtime.getSnapshot(host.serverId);
return {
serverId: host.serverId,
connectionStatus: snapshot?.connectionStatus,
clientGeneration: snapshot?.clientGeneration,
};
});
});
console.log("Fleet ordinary route counts", {
appSockets,
activeDaemonSockets: activeDaemonSockets.size,
routeOpens,
owners,
observationRequests,
});
} finally {
await fleet.close();
}
});
async function openFleetParentWithDirectB(
page: Page,
fleet: Awaited<ReturnType<typeof delegateFleetThroughMcp>>,
) {
await page.goto("/", { waitUntil: "commit" });
const host = buildSeededHost({
serverId: fleet.remote.serverId,
endpoint: `127.0.0.1:${fleet.destination.port}`,
label: "Fleet B",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"MCP child on B",
);
}
async function openFleetChild(
page: Page,
fleet: Awaited<ReturnType<typeof delegateFleetThroughMcp>>,
) {
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
await expect(page).toHaveURL(
(url) => url.pathname === `/h/${fleet.remote.serverId}/workspace/${fleet.remote.workspaceId}`,
);
}
test("a directly connected B opens and archives its MCP child from A's track", async ({ page }) => {
const fleet = await delegateFleetThroughMcp();
try {
await openFleetParentWithDirectB(page, fleet);
await openFleetChild(page, fleet);
await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
await submitMessage(page, "DIRECT_B_TARGET");
await expect(page.getByText("DIRECT_B_TARGET", { exact: true })).toBeVisible();
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
await openFleetParentWithDirectB(page, fleet);
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).hover();
page.once("dialog", (dialog) => {
void dialog.accept();
});
await page.getByRole("button", { name: "Archive MCP child on B", exact: true }).click();
await expectManagedSubagentArchived({ client: fleet.b }, fleet.remote.agentId);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toHaveCount(0);
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.archivedAt).toBeNull();
await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
} finally {
await fleet.close();
}
});
test("an unavailable B child opens its cached workspace with existing reconnecting behavior", async ({
page,
}) => {
const fleet = await delegateFleetThroughMcp();
try {
await openFleetParentWithDirectB(page, fleet);
await fleet.destination.close();
await expect
.poll(() =>
page.evaluate((serverId) => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
getSnapshot(id: string): { serverId: string; connectionStatus: string } | null;
};
return runtime.getSnapshot(serverId);
}, fleet.remote.serverId),
)
.toMatchObject({
serverId: fleet.remote.serverId,
connectionStatus: expect.stringMatching(/^(offline|connecting|error)$/),
});
await openFleetChild(page, fleet);
await expect(page.getByTestId("workspace-header-title").filter({ visible: true })).toHaveText(
"B workspace",
);
await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
} finally {
await fleet.close();
}
});
@@ -404,7 +404,7 @@ async function fetchWorkspaceStatuses(
}
test.describe("Workspace model restart regressions", () => {
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
test("browser-created same-cwd workspace preserves repaired restart status and migrated tab ownership", async ({
page,
baseURL,
}) => {
@@ -423,9 +423,13 @@ test.describe("Workspace model restart regressions", () => {
.toMatchObject({
id: LEGACY_AGENT_ID,
workspaceId: seeded.workspaceA,
status: "running",
status: "closed",
});
await expect
.poll(() => fetchWorkspaceStatuses(client, [seeded.workspaceA, seeded.workspaceB]))
.toEqual({ [seeded.workspaceA]: "done", [seeded.workspaceB]: "done" });
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowDoesNotShowIndicator(page, {
@@ -472,15 +476,11 @@ test.describe("Workspace model restart regressions", () => {
]),
)
.toMatchObject({
[seeded.workspaceA]: "done",
[seeded.workspaceB]: "done",
[createdWorkspaceId]: "done",
});
// The restarted provider session may settle while the browser creates the sibling. Its
// initial running status is asserted above; this phase verifies that ownership never moves.
const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
+12 -7
View File
@@ -1,10 +1,15 @@
const HIDE_DEVELOPER_TOASTS = "#error-toast { display: none !important; }";
/** @param {import('@playwright/test').Page} page */
export async function installDeveloperToastDismissal(page) {
export async function hideDeveloperToasts(page) {
// Developer chrome must not cover app actions or move the test's pointer/focus.
// Console errors remain in the runner's logs.
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
// A new logged error can replace the toast before the old one disappears.
noWaitAfter: true,
});
// Console errors remain in the runner's logs. Hiding the toast, instead of clicking it away,
// leaves no race with a toast that closes or is replaced between matching and clicking.
await page.addInitScript((css) => {
const style = document.createElement("style");
style.textContent = css;
document.addEventListener("DOMContentLoaded", () => document.head.append(style));
}, HIDE_DEVELOPER_TOASTS);
// A page attached after it loaded (Electron over CDP) runs no init script until it navigates.
await page.addStyleTag({ content: HIDE_DEVELOPER_TOASTS });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { installDeveloperToastDismissal } from "./developer-ui.mjs";
import { hideDeveloperToasts } from "./developer-ui.mjs";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { startE2EWorker } from "./helpers/e2e-worker";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -131,7 +131,7 @@ const test = daemonTest.extend<{
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await installDeveloperToastDismissal(page);
await hideDeveloperToasts(page);
const entries: string[] = [];
@@ -0,0 +1,77 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { expectFileTabOpen, openFileFromExplorer } from "./file-explorer";
/** A directory plugin whose "Rich Markdown" file panel claims `.md`, next to the built-in. */
export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
export const SOURCE_OPTION = "Source — Paseo";
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
function RichMarkdown({ file }) {
return <View><Text>Rich Markdown plugin: {file.name}</Text></View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType",
files: { extensions: ["md"] }, Component: RichMarkdown });
return () => {};
}`;
/** Enables plugins on the test daemon and installs the plugin; `uninstall` restores both. */
export async function installMarkdownPanelPlugin(): Promise<{ uninstall(): Promise<void> }> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-markdown-file-panel-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const uninstall = async () => {
await client.removePlugin(MARKDOWN_PANEL_PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await rm(directory, { recursive: true, force: true });
};
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MARKDOWN_PANEL_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
} catch (error) {
await uninstall();
throw error;
}
return { uninstall };
}
export function filePanelPicker(page: Page) {
return page.getByText("Open .md files with", { exact: true });
}
export async function openMarkdown(page: Page, name: string): Promise<void> {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
export async function expectBuiltinMarkdown(page: Page, heading: string): Promise<void> {
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
}
export async function expectPluginMarkdown(page: Page, name: string): Promise<void> {
await expect(page.getByText(`Rich Markdown plugin: ${name}`, { exact: true })).toBeVisible();
}
export async function storedFilePanelAssociations(page: Page): Promise<unknown> {
return page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("@paseo:app-settings") ?? "{}");
return settings.filePanelAssociations ?? {};
});
}
@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
@@ -274,3 +274,40 @@ export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promi
run,
});
}
const THOUGHT =
"Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold.";
/** Exercises the maintained inline-thinking renderer against real streamed reasoning. */
export async function viewInlineThinkingExample(page: Page, info: TestInfo): Promise<void> {
info.setTimeout(120_000);
const example = await copyPluginExample("inline-thinking");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const agent = await seedMockAgentWorkspace({
repoPrefix: "inline-thinking-",
title: "Inline thinking example",
model: "one-minute-stream",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await requestPluginTimeline(agent);
const thought = page.getByText(/^Need to find the scroll container/);
await expect(thought).toBeVisible({ timeout: 60_000 });
await expect(thought).not.toHaveText(THOUGHT);
await info.attach("inline-thinking-streaming", {
body: await page.screenshot({ path: info.outputPath("inline-thinking-streaming.png") }),
contentType: "image/png",
});
await expect(thought).toHaveText(THOUGHT, { timeout: 30_000 });
} finally {
await client.removePlugin("inline-thinking");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await agent.cleanup();
await example.cleanup();
}
}
@@ -0,0 +1,164 @@
import { hideDeveloperToasts } from "../developer-ui.mjs";
import { seedWorkspace } from "./seed-client";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import type { TestInfo, BrowserContext } from "@playwright/test";
import { expect, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
async function chooseChinese(page: Page) {
await openSettings(page);
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "简体中文 - Simplified Chinese", exact: true }).click();
await page.goBack();
}
async function chooseEnglish(page: Page) {
await page.getByTestId("sidebar-settings").click();
await page.getByRole("button", { name: "简体中文", exact: true }).click();
await page.getByRole("menuitem", { name: "English - 英语", exact: true }).click();
await page.goBack();
}
async function capture(page: Page, info: TestInfo, name: string) {
const file = info.outputPath(`${name}.png`);
await page.screenshot({ path: file, animations: "disabled" });
await info.attach(name, { path: file, contentType: "image/png" });
}
export async function verifyPluginTranslations(page: Page, info: TestInfo) {
const example = await copyPluginExample("translations");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const browser = page.context().browser();
if (!browser) throw new Error("Translation isolation requires a browser");
let secondContext: BrowserContext | undefined;
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Workspace notes", exact: true }).click();
await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
await page
.getByRole("textbox", { name: "Your draft", exact: true })
.fill("Remember the language switch");
await capture(page, info, "01-english-draft");
await page.getByRole("button", { name: "Preview draft", exact: true }).click();
await expect(page.getByRole("button", { name: "Close preview", exact: true })).toBeVisible();
await capture(page, info, "02-english-preview");
await page.getByRole("button", { name: "Close preview", exact: true }).click();
secondContext = await browser.newContext({
storageState: await page.context().storageState(),
viewport: { width: 1440, height: 1000 },
});
const second = await secondContext.newPage();
await hideDeveloperToasts(second);
await second.goto(page.url());
await expect(second.getByText("App language: en", { exact: true })).toBeVisible();
await chooseChinese(second);
await expect(second.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
await second
.getByRole("textbox", { name: "你的草稿", exact: true })
.fill("A separate client draft");
await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
await capture(second, info, "03-independent-chinese-client");
await chooseChinese(page);
await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
"Remember the language switch",
);
await expect(page.getByTestId("plugin-surface-title")).toHaveText("工作区笔记");
await expect(page.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
await expect(
page.getByText("This English-only message demonstrates fallback.", { exact: true }),
).toBeVisible();
await capture(page, info, "04-chinese-draft-preserved");
await page.getByRole("button", { name: "预览草稿", exact: true }).click();
await expect(page.getByText("草稿预览", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "关闭预览", exact: true })).toBeVisible();
await capture(page, info, "05-chinese-preview");
await page.getByRole("button", { name: "关闭预览", exact: true }).click();
await chooseEnglish(page);
await expect(page.getByRole("textbox", { name: "Your draft", exact: true })).toHaveValue(
"Remember the language switch",
);
await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
await expect(second.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
"A separate client draft",
);
await capture(page, info, "06-return-to-english-draft-preserved");
await page.getByRole("button", { name: "Add note", exact: true }).click();
await expect(page.getByText("1 note", { exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "Your draft", exact: true }).fill("Second note");
await page.getByRole("button", { name: "Add note", exact: true }).click();
await expect(page.getByText("2 notes", { exact: true })).toBeVisible();
} finally {
await secondContext?.close().catch(() => undefined);
await client.removePlugin("translation-notes").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await example.cleanup();
}
}
export async function verifyPluginToolTranslations(page: Page, info: TestInfo) {
const workspace = await seedWorkspace({
repoPrefix: "translation-tools-",
title: "Notes translation QA",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Notes",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const example = await copyPluginExample("translations");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Show notes tools");
await commandCenter.getByRole("button", { name: "Show notes tools", exact: true }).click();
const tools = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "Notes tools", exact: true });
await tools.hover();
await expect(page.getByRole("tooltip")).toContainText("Notes tools");
await capture(page, info, "07-english-tooltip");
await tools.click();
await page.getByRole("menuitem", { name: "Open notes panel", exact: true }).click();
await page
.getByRole("textbox", { name: "Your draft", exact: true })
.fill("A workspace panel draft");
await chooseChinese(page);
await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
"A workspace panel draft",
);
const translatedTools = page
.getByRole("toolbar")
.getByRole("button", { name: "笔记工具", exact: true });
await translatedTools.hover();
await expect(page.getByRole("tooltip")).toContainText("笔记工具");
await capture(page, info, "08-chinese-tooltip-and-panel-draft");
await translatedTools.click();
await expect(page.getByRole("menuitem", { name: "打开笔记面板", exact: true })).toBeVisible();
await capture(page, info, "09-chinese-menu");
await page.keyboard.press("Escape");
} finally {
await client.removePlugin("translation-notes").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -24,6 +24,7 @@ const SECTION_LABELS = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
+153 -2
View File
@@ -1,3 +1,7 @@
import { connectDaemonClient } from "./daemon-client-loader";
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace } from "./seed-client";
import { getE2EDaemonPort } from "./daemon-port";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -166,7 +170,7 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
}
export async function expectManagedSubagentUnarchived(
workspace: Pick<SeededWorkspace, "client">,
workspace: { client: Pick<SeededWorkspace["client"], "fetchAgent"> },
childId: string,
): Promise<void> {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
@@ -175,7 +179,7 @@ export async function expectManagedSubagentUnarchived(
}
export async function expectManagedSubagentArchived(
workspace: Pick<SeededWorkspace, "client">,
workspace: { client: Pick<SeededWorkspace["client"], "fetchAgent"> },
childId: string,
): Promise<void> {
await expect
@@ -320,3 +324,150 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}
/** The primary children come from the real agent MCP endpoint on A. */
export async function delegateFleetThroughMcp() {
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } =
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
const { z } = await import("zod");
type DaemonClient = import("@getpaseo/client/internal/daemon-client").DaemonClient;
const workspace = await seedWorkspace({ repoPrefix: "fleet-mcp-" });
const destination = await startIsolatedHostDaemon(`fleet_${crypto.randomUUID()}`);
const a = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-seed-A" });
const b = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "fleet-observe-B",
port: destination.port,
});
const serverId = a.getLastServerInfoMessage()!.serverId;
const mcp = new Client({ name: "fleet-parent", version: "1" });
const close = async () => {
await mcp.close();
await Promise.allSettled([a.close(), b.close()]);
await destination.close();
await workspace.cleanup();
};
const call = async (name: string, args: Record<string, unknown> = {}) => {
const response = await mcp.callTool({ name, arguments: args });
expect(response, name).not.toMatchObject({ isError: true });
return z.record(z.string(), z.unknown()).parse(response.structuredContent);
};
try {
const parent = await a.createAgent({
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Fleet parent",
modeId: "load-test",
});
const registration = await a.addHost({
connection: `127.0.0.1:${destination.port}`,
label: "Fleet B",
});
await expect
.poll(
async () =>
(await a.listHosts()).hosts.find(
(row) => row.registrationId === registration.registrationId,
)?.status,
)
.toBe("online");
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
),
);
expect(await call("list_hosts")).toMatchObject({
hosts: expect.arrayContaining([
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
]),
});
const target = await call("create_workspace", {
serverId: destination.serverId,
isolation: "local",
path: workspace.repoPath,
title: "B workspace",
});
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
workspaces: expect.arrayContaining([
expect.objectContaining({ workspaceId: target.workspaceId }),
]),
});
const createChild = async (targetServerId: string, workspaceId: string, title: string) => {
const result = await call("create_agent", {
serverId: targetServerId,
workspaceId,
title,
provider: "mock/e2e-fast-stream",
settings: { modeId: "load-test" },
initialPrompt: `Finish ${title}`,
});
return {
serverId: targetServerId,
agentId: z.string().parse(result.agentId),
workspaceId,
title,
};
};
const local = await createChild(serverId, workspace.workspaceId, "MCP child on A");
const remote = await createChild(
destination.serverId,
z.string().parse(target.workspaceId),
"MCP child on B",
);
// Existing SDK reservation creates unrelated equal-ID fixtures; primary children remain MCP-created.
await b.createAgent({
agentId: local.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: String(target.workspaceId),
title: "Unrelated on B",
modeId: "load-test",
});
await a.createAgent({
agentId: remote.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Unrelated on A",
modeId: "load-test",
});
return {
workspace,
destination,
parent,
serverId,
local,
remote,
a,
b,
close,
async expectCompletion(child: typeof local) {
await expect
.poll(async () => {
const timeline = await a.fetchAgentTimeline(parent.id, {
direction: "tail",
limit: 100,
});
return timeline.entries
.flatMap(({ item }) =>
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
)
.filter(
(message) =>
message.event === "finished" &&
message.sender.id === child.agentId &&
message.text.includes(`Server: ${child.serverId}`),
).length;
})
.toBe(1);
},
};
} catch (error) {
await close();
throw error;
}
}
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
},
};
}
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
+2
View File
@@ -9,6 +9,8 @@ import "@expo/metro-runtime";
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
import "expo-router/build/fast-refresh";
import { renderRootComponent } from "expo-router/build/renderRootComponent";
import { startWatchdog } from "./src/watchdog";
import { RootApp } from "./src/root-app";
startWatchdog();
renderRootComponent(RootApp);
+10
View File
@@ -5,6 +5,7 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
@@ -16,6 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.toLowerCase();
const config = getDefaultConfig(projectRoot);
config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard";
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
@@ -75,6 +77,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (
isWatchdogBuild &&
platform === "android" &&
/[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) &&
/implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName)
) {
return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform);
}
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
@@ -7,6 +7,7 @@
#include <stdexcept>
#include <unordered_map>
#include "include/ports/SkFontMgr_android.h"
#include "include/ports/SkFontScanner_FreeType.h"
#include "modules/skunicode/include/SkUnicode.h"
namespace {
@@ -86,7 +87,7 @@ extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(prepareDocument)(
const auto fontFamily = SkUnicode::convertUtf16ToUtf8(copyString(env, family));
const double copyMs = elapsed(copyStart);
const auto layoutStart = Clock::now();
paseo::diff::prepare(*document, SkFontMgr_New_Android(nullptr), fontFamily.c_str(), size, job->cancelled);
paseo::diff::prepare(*document, SkFontMgr_New_Android(nullptr, SkFontScanner_Make_FreeType()), fontFamily.c_str(), size, job->cancelled);
const double layoutMs = elapsed(layoutStart);
{
std::lock_guard lock(mutex);
@@ -13,6 +13,7 @@
#include "modules/skparagraph/include/Paragraph.h"
#include "modules/skparagraph/include/ParagraphBuilder.h"
#include "modules/skunicode/include/SkUnicode.h"
#include "modules/skunicode/include/SkUnicode_icu.h"
namespace paseo::diff {
namespace {
@@ -178,7 +179,7 @@ class Measurer {
return it->second;
}
std::unique_ptr<para::Paragraph> shape(const std::u16string& text) {
auto builder = para::ParagraphBuilder::make(style_, fonts_);
auto builder = para::ParagraphBuilder::make(style_, fonts_, SkUnicodes::ICU::Make());
builder->pushStyle(textStyle_);
const auto utf8 = SkUnicode::convertUtf16ToUtf8(text);
builder->addText(utf8.c_str(), utf8.size());
@@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
dependencies {
testImplementation "junit:junit:4.13.2"
}
@@ -0,0 +1,77 @@
package sh.paseo.trace
import android.content.Context
import android.os.SystemClock
import android.util.Log
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
/** All serialization and storage belongs to the worker, never the JS/UI threads. */
internal class DiagnosticRecorder(private val context: Context, private val metadata: Map<String, String>) {
private data class Event(val name: String, val data: Map<String, String>, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis())
private val queue = ArrayBlockingQueue<Event>(2048)
private val dropped = AtomicLong()
private val worker = Executors.newSingleThreadScheduledExecutor()
private val session = UUID.randomUUID().toString()
@Volatile private var failed = false
private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record)
private val log = RotatingLog(DownloadsLogStorage(context, session), { part ->
serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part")))
}, { error ->
failed = true
queue.clear()
Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error)
})
init {
record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}"))
worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS)
}
fun record(name: String, data: Map<String, String>) {
if (failed) return
// Limit native memory even if an instrumentation caller accidentally supplies large fields.
val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) }
if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet()
}
fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime())
fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime())
private fun drain() {
if (failed) return
watchdog.check(SystemClock.elapsedRealtime())
val lost = dropped.getAndSet(0)
if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost")))
var remaining = 2048
while (remaining-- > 0 && !failed) {
val event = queue.poll() ?: break
write(event)
}
log.finishBatch()
}
private fun write(event: Event) = log.write(serialize(event))
private fun serialize(event: Event): ByteArray = (JSONObject().apply {
put("event", event.name)
put("session", session)
put("wallMs", event.wallMs)
put("monoMs", event.monoMs)
put("data", JSONObject(event.data))
}.toString() + "\n").toByteArray(Charsets.UTF_8)
fun close() {
worker.execute {
record("session.end", emptyMap())
drain()
log.finishBatch()
}
worker.shutdown()
}
}
@@ -0,0 +1,36 @@
package sh.paseo.trace
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import java.io.OutputStream
internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage {
private val resolver = context.contentResolver
private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
private val directory = "Download/Paseo diagnostics/"
override fun create(part: Int): String {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-$part.jsonl")
put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson")
put(MediaStore.MediaColumns.RELATIVE_PATH, directory)
}
return checkNotNull(resolver.insert(collection, values)).toString()
}
override fun append(id: String): OutputStream =
checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024)
override fun newest(): List<String> = buildList {
resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?",
arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor ->
while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString())
}
}
override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) }
}
@@ -0,0 +1,38 @@
package sh.paseo.trace
internal object HeartbeatTiming {
const val intervalMs = 250L
const val overdueMs = 500L
const val gapMs = intervalMs + overdueMs
}
/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */
internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<String, String>) -> Unit) {
private var foreground = true
private var lastHeartbeat = startMs
private var overdue = false
@Synchronized fun heartbeat(now: Long) {
if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) {
emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}"))
}
lastHeartbeat = now
overdue = false
}
@Synchronized fun active(value: Boolean, now: Long) {
if (foreground == value) return
emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue"))
foreground = value
lastHeartbeat = now
overdue = false
}
@Synchronized fun check(now: Long) {
val gap = now - lastHeartbeat
if (foreground && gap > HeartbeatTiming.gapMs && !overdue) {
emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat"))
overdue = true
}
}
}
@@ -5,9 +5,23 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoNativeTraceModule : Module() {
private var recorder: DiagnosticRecorder? = null
override fun definition() = ModuleDefinition {
Name("PaseoNativeTrace")
Function("startRecorder") { metadata: Map<String, String> ->
if (recorder == null) {
appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) }
}
HeartbeatTiming.intervalMs
}
Function("record") { name: String, data: Map<String, String> -> recorder?.record(name, data) }
Function("heartbeat") { recorder?.heartbeat() }
OnActivityEntersForeground { recorder?.active(true) }
OnActivityEntersBackground { recorder?.active(false) }
OnDestroy { recorder?.close(); recorder = null }
Function("beginSection") { name: String ->
Trace.beginSection(name.take(127))
}
@@ -0,0 +1,61 @@
package sh.paseo.trace
import java.io.OutputStream
/** Storage supplies installation-owned files, newest first. */
internal interface LogStorage {
fun create(part: Int): String
fun append(id: String): OutputStream
fun newest(): List<String>
fun delete(id: String)
}
/** Owns file identity, bounded retention and the terminal storage-failure state. */
internal class RotatingLog(
private val storage: LogStorage,
private val header: (Int) -> ByteArray,
private val onFailure: (Exception) -> Unit,
private val maxBytes: Int = 5 * 1024 * 1024,
) {
private var current: String? = null
private var output: OutputStream? = null
private var size = 0
private var part = 0
var failed = false
private set
fun write(bytes: ByteArray) = attempt {
if (current == null || size + bytes.size > maxBytes) {
finishBatch()
if (failed) return@attempt
val nextPart = part++
current = storage.create(nextPart)
output = storage.append(checkNotNull(current))
val first = header(nextPart)
output!!.write(first)
size = first.size
storage.newest().drop(8).forEach(storage::delete)
}
if (output == null) output = storage.append(checkNotNull(current))
output!!.write(bytes)
size += bytes.size
}
// Closing publishes size and modification time in Android Files.
fun finishBatch() = attempt {
output?.close()
output = null
}
private fun attempt(action: () -> Unit) {
if (failed) return
try {
action()
} catch (error: Exception) {
failed = true
runCatching { output?.close() }
output = null
onFailure(error)
}
}
}
@@ -0,0 +1,40 @@
package sh.paseo.trace
import org.junit.Assert.assertEquals
import org.junit.Test
class HeartbeatWatchdogTest {
@Test fun recordsOneStartAndRecoveryWithMeasuredGap() {
val events = mutableListOf<Pair<String, Map<String, String>>>()
val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) }
watchdog.heartbeat(250)
watchdog.check(1000)
assertEquals(emptyList<Pair<String, Map<String, String>>>(), events)
watchdog.check(1001)
watchdog.check(2000)
watchdog.heartbeat(2250)
assertEquals(listOf(
"js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"),
"js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750")
), events)
}
@Test fun backgroundAndResumeDoNotLookLikeStalls() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.active(false, 100)
watchdog.check(300000)
watchdog.heartbeat(300100)
watchdog.active(true, 400000)
watchdog.heartbeat(400250)
watchdog.check(400500)
assertEquals(listOf("app.lifecycle", "app.lifecycle"), events)
}
@Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.heartbeat(900)
assertEquals(listOf("js.stall.recovered"), events)
}
}
@@ -0,0 +1,59 @@
package sh.paseo.trace
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
import org.junit.Assert.*
import org.junit.Test
class RotatingLogTest {
private class MemoryStorage : LogStorage {
val files = linkedMapOf<String, ByteArrayOutputStream>()
var fail = false
var opens = 0
override fun create(part: Int): String {
val id = "part-$part"
files[id] = ByteArrayOutputStream()
return id
}
override fun append(id: String): OutputStream {
opens++
if (fail) throw IOException("disk full")
return files.getValue(id)
}
override fun newest() = files.keys.reversed()
override fun delete(id: String) { files.remove(id) }
}
@Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20)
repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() }
assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList())
for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8"))
assertFalse(log.failed)
}
@Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5)
log.write("12".toByteArray())
log.finishBatch()
log.write("34".toByteArray())
assertEquals(listOf("part-0"), storage.files.keys.toList())
assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8"))
log.write("5".toByteArray())
assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8"))
}
@Test fun storageFailureStopsRecordingWithoutRetryStorm() {
val storage = MemoryStorage()
var failures = 0
val log = RotatingLog(storage, { byteArrayOf() }, { failures++ })
storage.fail = true
repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() }
assertTrue(log.failed)
assertEquals(1, failures)
assertEquals(1, storage.opens)
}
}
+9 -4
View File
@@ -8,12 +8,12 @@
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android": "npm run dev:android",
"android:development": "npm run dev:android",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "npm --prefix ../.. run build:client && expo run:ios",
"ios": "npm run dev:ios",
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
"web:expo": "expo start --web",
@@ -36,7 +36,12 @@
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -1,3 +1,4 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import type { InstalledPlugin } from "@/plugins/types";
@@ -61,6 +62,8 @@ function installProbe(
serverId: "host-1",
clientBundle,
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
@@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/** Authored document links resolve from this directory, independently of the workspace. */
relativePathBase?: string;
/** Authored links identify a file; only inferred text needs filename heuristics. */
explicitLink?: boolean;
/**
@@ -281,6 +283,7 @@ export function parseAssistantFileLink(
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
explicitLink: options.explicitLink,
relativePathBase: options.relativePathBase,
});
if (relativeTarget) {
return relativeTarget;
@@ -395,6 +398,15 @@ function parseWorkspaceRelativeFileLink(
};
}
const documentBase = options.explicitLink && normalizePathInput(options.relativePathBase);
if (documentBase) {
return {
raw: value,
path: resolveDocumentRelativePath(parsed.path, documentBase),
...parsed.lines,
};
}
const workspaceRoot = normalizePathInput(options.workspaceRoot);
if (!workspaceRoot) {
return null;
@@ -638,6 +650,24 @@ function isDomainLikePathSegment(segment: string): boolean {
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment);
}
function resolveDocumentRelativePath(relativePath: string, baseDirectory: string): string {
const prefix = baseDirectory.startsWith("/") ? "/" : "";
const segments = baseDirectory.split("/").filter(Boolean);
for (const segment of relativePath.split("/")) {
const last = segments.at(-1) ?? "";
if (!segment || segment === ".") continue;
if (segment !== "..") {
segments.push(segment);
} else if (last && last !== "~" && last !== ".." && !/^[A-Za-z]:$/.test(last)) {
segments.pop();
} else if (!prefix && !/^[A-Za-z]:$/.test(last)) {
segments.push("..");
}
}
const resolved = prefix + segments.join("/");
return /^[A-Za-z]:$/.test(resolved) ? `${resolved}/` : resolved;
}
function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null {
const normalizedPath = normalizePathToken(pathValue);
if (!normalizedPath || isAbsolutePath(normalizedPath)) {
@@ -22,6 +22,7 @@ export interface AssistantFileLinkResolverConfig {
client?: AssistantFileLinkDaemonClient | null;
serverId?: string;
workspaceRoot?: string;
relativePathBase?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
@@ -42,6 +43,7 @@ export function AssistantFileLinkResolverProvider({
client,
serverId,
workspaceRoot,
relativePathBase,
onOpenWorkspaceFile,
toast,
children,
@@ -50,10 +52,18 @@ export function AssistantFileLinkResolverProvider({
client,
serverId,
workspaceRoot,
relativePathBase,
onOpenWorkspaceFile,
toast,
});
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
configRef.current = {
client,
serverId,
workspaceRoot,
relativePathBase,
onOpenWorkspaceFile,
toast,
};
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
const activeClient = configRef.current.client;
@@ -18,6 +18,50 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
it.each([
["guide/details.md", "/repo/docs/guide/details.md"],
["../README.md", "/repo/README.md"],
["guide/details%20page.md", "/repo/docs/guide/details page.md"],
])("resolves authored href %s from its document directory", (href, expected) => {
expect(
classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase: "/repo/docs" }),
).toMatchObject({ kind: "resolved", value: { kind: "file", target: { path: expected } } });
});
it.each([
["C:/repo/docs", "../README.md", "C:/repo/README.md"],
["C:/repo/docs", "../../", "C:/"],
["~/.paseo/docs", "../guide.md", "~/.paseo/guide.md"],
["/repo/docs", "/other/file.md", "/other/file.md"],
["/repo/docs", "file:///other/file.md", "/other/file.md"],
["/repo/docs", "guide/details.md#L3", "/repo/docs/guide/details.md"],
])(
"resolves %s with href %s without changing absolute destinations",
(relativePathBase, href, expected) => {
expect(
classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase }),
).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: expected } },
});
},
);
it("keeps inferred paths and chat links rooted at the workspace", () => {
const context = { workspaceRoot: "/repo", relativePathBase: "/repo/docs" };
expect(classifyForResolution({ href: "src/file.ts", markup: "linkify" }, context)).toMatchObject({
value: { kind: "file", target: { path: "/repo/src/file.ts" } },
});
expect(
classifyForResolution({ href: "guide/details.md" }, { workspaceRoot: "/repo" }),
).toMatchObject({
value: { kind: "file", target: { path: "/repo/guide/details.md" } },
});
expect(classifyForResolution({ href: "https://example.com/docs" }, context)).toMatchObject({
value: { kind: "external", url: "https://example.com/docs" },
});
});
it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])(
"resolves an explicit extensionless folder href: %s",
(href) => {
@@ -17,6 +17,7 @@ export interface AssistantFileLinkSource {
export interface AssistantFileLinkContext {
workspaceRoot?: string;
relativePathBase?: string;
}
export interface DirectorySuggestionEntry {
@@ -118,6 +119,7 @@ export function classifyForResolution(
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
relativePathBase: context.relativePathBase,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -15,6 +15,7 @@ import {
fetchDaemonResolution,
type AssistantFileLinkResolution,
type AssistantFileLinkSource,
type AssistantFileLinkContext,
} from "./resolver";
export interface UseFileLinkResult {
@@ -54,13 +55,15 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
const stableSource = useStableSource(source);
const activeConfig = context.configRef.current;
const workspaceRoot = activeConfig.workspaceRoot;
const relativePathBase = activeConfig.relativePathBase;
const serverId = activeConfig.serverId;
const resolution = useMemo(
() =>
classifyForResolution(stableSource, {
workspaceRoot,
relativePathBase,
}),
[stableSource, workspaceRoot],
[stableSource, workspaceRoot, relativePathBase],
);
const queryKey = useMemo(
() =>
@@ -154,12 +157,12 @@ export function useAssistantFileLinkActions(): AssistantFileLinkActions {
);
const canOpen = useCallback(
(source: AssistantFileLinkSource) =>
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
canOpenAssistantFileLink(source, context.configRef.current),
[context.configRef],
);
const canResolveFile = useCallback(
(source: AssistantFileLinkSource) =>
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
canResolveAssistantFileLinkToFile(source, context.configRef.current),
[context.configRef],
);
@@ -176,6 +179,7 @@ function openAssistantFileLink(input: {
const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, {
workspaceRoot: capturedConfig.workspaceRoot,
relativePathBase: capturedConfig.relativePathBase,
});
if (capturedResolution.kind === "resolved") {
@@ -233,17 +237,17 @@ function openAssistantFileLink(input: {
function canOpenAssistantFileLink(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
context: AssistantFileLinkContext,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
const resolution = classifyForResolution(source, context);
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
}
function canResolveAssistantFileLinkToFile(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
context: AssistantFileLinkContext,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
const resolution = classifyForResolution(source, context);
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
}
@@ -0,0 +1,59 @@
import { X } from "lucide-react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedX = withUnistyles(X);
const NO_SHORTCUT: ShortcutKey[] = [];
interface SidebarCloseButtonProps {
label: string;
onPress: () => void;
testID: string;
nativeID?: string;
accessibilityState?: { expanded: boolean };
style?: StyleProp<ViewStyle>;
}
/**
* The X that closes a compact sidebar overlay. It is the compact header's icon button, so the
* sidebar's close reads like the header it covers; every compact sidebar uses it.
*/
export function SidebarCloseButton({
label,
onPress,
testID,
nativeID,
accessibilityState,
style,
}: SidebarCloseButtonProps) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={style}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
+11 -40
View File
@@ -5,7 +5,6 @@ import {
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -21,14 +20,9 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -68,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
@@ -81,8 +74,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const ThemedX = withUnistyles(X);
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
@@ -555,7 +546,15 @@ function MobileSidebar({
}, [closeSidebar]);
const closeButton = useMemo(
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
() => (
<SidebarCloseButton
label={labels.closeSidebar}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton}
/>
),
[labels.closeSidebar, closeSidebar],
);
@@ -621,34 +620,6 @@ function MobileSidebar({
);
}
const NO_SHORTCUT: ShortcutKey[] = [];
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={styles.mobileCloseButton}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={label}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
function DesktopSidebar({
theme,
workspaceGroups,
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
import type { GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -36,10 +36,9 @@ export function useLongPressDragInteraction(input: {
if (!menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
y: touchStartRef.current.y,
width: 0,
height: 0,
});
@@ -1,4 +1,4 @@
import React, { useMemo, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import {
View,
Text,
@@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
interface WorktreeSetupDetailProps {
log: string;
branchName: string;
worktreePath: string;
ds: DetailStyles;
}
function WorktreeSetupDetailSection({
log,
branchName,
worktreePath,
ds,
}: WorktreeSetupDetailProps) {
const setupLog = log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
return (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
}
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
@@ -678,17 +636,7 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup") {
return [
<WorktreeSetupDetailSection
key="worktree-setup"
log={detail.log}
branchName={detail.branchName}
worktreePath={detail.worktreePath}
ds={ds}
/>,
];
}
if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
@@ -6,8 +6,6 @@ import {
type Ref,
} from "react";
import {
Platform,
StatusBar,
View,
type GestureResponderEvent,
type PressableProps,
@@ -133,10 +131,9 @@ export function ContextMenuTrigger({
const point = coerceEventPoint(event);
if (!point) return;
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
ctx.setAnchorRect({
x: point.pageX,
y: point.pageY + statusBarHeight,
y: point.pageY,
width: 0,
height: 0,
});
@@ -14,7 +14,6 @@ import {
Modal,
Platform,
Pressable,
StatusBar,
View,
type StyleProp,
type ViewStyle,
@@ -162,16 +161,13 @@ export function useAnchoredPosition({
return undefined;
}
// Capture status bar height synchronously before async measurement, so it cannot change
// or read back null between the measure call and its resolution.
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
// The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one
// coordinate space that starts at the screen top; no status bar offset applies.
void measureElement(anchorRef.current).then((rect) => {
if (cancelled) return undefined;
// On Android with statusBarTranslucent, measureInWindow returns coordinates relative to
// below the status bar while Modal content starts at the screen top.
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
setTriggerRect(rect);
return undefined;
});
+1 -3
View File
@@ -18,7 +18,6 @@ import {
Platform,
Modal,
Pressable,
StatusBar,
View,
type PressableProps,
type StyleProp,
@@ -474,11 +473,10 @@ export function TooltipContent({
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(ctx.triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
if (!cancelled) setTriggerRect(rect);
return undefined;
});
+1 -1
View File
@@ -54,7 +54,7 @@ export interface ComposerSendClient {
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
) => Promise<unknown>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
+7
View File
@@ -315,6 +315,7 @@ interface PendingFileAttachment {
}
interface RenderAttachmentTrayArgs {
serverId: string;
selectedAttachments: ComposerAttachment[];
pendingFiles: PendingFileAttachment[];
isComposerLocked: boolean;
@@ -331,6 +332,7 @@ interface RenderAttachmentTrayArgs {
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const {
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -343,6 +345,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
serverId,
attachment,
index,
disabled: isComposerLocked,
@@ -393,6 +396,7 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
}
interface RenderComposerAttachmentPillArgs {
serverId: string;
attachment: ComposerAttachment;
index: number;
disabled: boolean;
@@ -453,6 +457,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
serverId={args.serverId}
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
@@ -2295,6 +2300,7 @@ function ComposerContentImpl({
const attachmentTray = useMemo(
() =>
renderAttachmentTray({
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -2314,6 +2320,7 @@ function ComposerContentImpl({
handleOpenAttachment,
handleRemoveAttachment,
isComposerLocked,
serverId,
selectedAttachments,
pendingFiles,
t,
@@ -56,10 +56,7 @@ export type { DraftInput } from "@/stores/draft-store";
export type {
Agent,
ExplorerEntry,
ExplorerFile,
ExplorerEntryKind,
ExplorerFileKind,
ExplorerEncoding,
AgentFileExplorerState,
} from "@/stores/session-store";
+7 -6
View File
@@ -51,7 +51,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
emit: <K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>) => void;
subscribeCheckoutDiffCalls: Array<{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
@@ -67,7 +67,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
};
const subscribeCheckoutDiffCalls: Array<{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
@@ -379,12 +379,12 @@ describe("server data push router", () => {
});
});
it("subscribes active checkout diff queries and writes matching diff events", () => {
it.each(["base", "all"] as const)("routes %s diff snapshots and updates", (mode) => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const queryKey = checkoutDiffQueryKey(serverId, cwd, mode, "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, {
queryKey,
@@ -397,17 +397,18 @@ describe("server data push router", () => {
serverId,
subscriptionId,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
compare: { mode, baseRef: "main", ignoreWhitespace: true },
}),
});
const unsubscribeObserver = observer.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId;
expect(fake.subscribeCheckoutDiffCalls).toEqual([
{
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
compare: { mode, baseRef: "main", ignoreWhitespace: true },
subscriptionId: serverSubscriptionId,
},
]);
+2 -2
View File
@@ -33,7 +33,7 @@ type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionI
type ListTerminalsPayload = ListTerminalsResponse["payload"];
interface CheckoutDiffCompare {
mode: "uncommitted" | "base";
mode: "uncommitted" | "base" | "all";
baseRef?: string;
ignoreWhitespace?: boolean;
}
@@ -655,7 +655,7 @@ function readCheckoutDiffCompare(value: unknown): CheckoutDiffCompare | null {
const mode = value.mode;
const baseRef = value.baseRef;
const ignoreWhitespace = value.ignoreWhitespace;
if (mode !== "uncommitted" && mode !== "base") {
if (mode !== "uncommitted" && mode !== "base" && mode !== "all") {
return null;
}
if (baseRef !== undefined && typeof baseRef !== "string") {
@@ -10,6 +10,7 @@ function connectionListen(connection: HostConnection): string | null {
case "directPipe":
return `pipe://${connection.path}`;
case "remoteSsh":
case "routed":
case "relay":
return null;
}
@@ -1,34 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { describe, expect, it } from "vitest";
import { explorerFileFromReadResult } from "./read-result";
function textRead(bytes: Uint8Array): FileReadResult {
return {
bytes,
mime: "text/plain",
size: bytes.byteLength,
path: "notes.txt",
kind: "text",
modifiedAt: "2026-07-21T00:00:00.000Z",
};
}
describe("explorerFileFromReadResult", () => {
it("records and hides a leading UTF-8 BOM", () => {
const file = explorerFileFromReadResult(
textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])),
);
expect(file).toMatchObject({ content: "hi", hasBom: true });
});
it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => {
const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi")));
const embedded = explorerFileFromReadResult(
textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])),
);
expect(plain.hasBom).toBe(false);
expect(embedded.hasBom).toBe(false);
});
});
@@ -1,21 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { ExplorerFile } from "@/stores/session-store";
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
const isText = file.kind === "text";
return {
path: file.path,
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
hasBom: isText && hasUtf8Bom(file.bytes),
mimeType: file.mime,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
function hasUtf8Bom(bytes: Uint8Array): boolean {
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
}
+25 -129
View File
@@ -1,126 +1,42 @@
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
import {
FilePanelBarMenu,
FilePanelSwitcher,
useFilePanelChoiceSnapshot,
type FilePanelChoice,
} from "@/file-panels";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function FilePanelBar({
size,
lineCount,
mode,
onModeChange,
editorStatus,
cursor,
vimMode,
conflict,
}: {
size: number;
lineCount?: number;
mode?: "preview" | "source";
onModeChange?(mode: "preview" | "source"): void;
editorStatus?: FileEditorStatus;
cursor?: { line: number; column: number };
vimMode?: string | null;
conflict?: FileConflictAlertState;
}) {
/**
* The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel
* has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest.
*/
export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) {
const { t } = useTranslation();
const previewModes = [
{
value: "preview" as const,
label: t("panels.file.editor.preview"),
testID: "file-mode-preview",
},
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
];
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
return (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.metadata}>
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
{lineCount !== undefined ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
) : null}
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus
? t("panels.file.editor.editorStatus", { status: editorStatus })
: undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
{mode && onModeChange ? (
<SegmentedControl
size="xs"
value={mode}
onValueChange={onModeChange}
testID="file-preview-mode"
options={previewModes}
<View style={styles.spacer} />
{modified ? (
<View
style={styles.modifiedDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
testID="file-panel-modified"
/>
) : null}
<FilePanelSwitcher choice={choice} />
<FilePanelBarMenu choice={choice} />
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
</View>
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
@@ -132,31 +48,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
dirtyDot: {
spacer: { flex: 1 },
modifiedDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
status: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
vim: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));
@@ -1,652 +0,0 @@
import { describe, expect, test } from "vitest";
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
import {
FileEditorModel,
getFileConflictCallout,
type FileEditorClock,
type FileEditorFile,
type FileEditorObservation,
type FileEditorSession,
type FileObservationSource,
} from "./model";
class TestClock implements FileEditorClock {
private callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout(): void {
this.callback = null;
}
fire(): void {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
class FileSession implements FileEditorSession {
file: FileEditorFile;
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
nextWrite: FileWriteResult | Error | null = null;
private pendingWrite: Promise<FileWriteResult> | null = null;
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
constructor(file: FileEditorFile) {
this.file = file;
}
async write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
this.writes.push(input);
if (this.pendingWrite) return this.pendingWrite;
if (this.nextWrite instanceof Error) throw this.nextWrite;
if (this.nextWrite) return this.nextWrite;
return {
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: input.content.length,
};
}
holdNextWrite(): void {
this.pendingWrite = new Promise((resolve) => {
this.resolvePendingWrite = resolve;
});
}
finishHeldWrite(result: FileWriteResult): void {
this.resolvePendingWrite?.(result);
this.pendingWrite = null;
this.resolvePendingWrite = null;
}
}
class ObservationSource implements FileObservationSource {
observation: FileEditorObservation | null;
refreshes = 0;
private readonly listeners = new Set<() => void>();
constructor(observation: FileEditorObservation | null) {
this.observation = observation;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
getObservation(): FileEditorObservation | null {
return this.observation;
}
refresh = (): void => {
this.refreshes += 1;
};
emit(observation: FileEditorObservation): void {
this.observation = observation;
for (const listener of this.listeners) listener();
}
}
function ready(
modifiedAt = "2026-07-18T00:00:00.000Z",
size = 3,
): Extract<FileVersion, { status: "ready" }> {
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
}
interface MakeModelInput {
content?: string;
hasBom?: boolean;
}
function makeModel(input: MakeModelInput = {}) {
const file = {
content: input.content ?? "one",
hasBom: input.hasBom ?? false,
version: ready() as Extract<FileVersion, { status: "ready" }>,
};
const session = new FileSession(file);
const clock = new TestClock();
return { model: new FileEditorModel({ file, session, clock }), session, clock };
}
function observeFile(model: FileEditorModel, file: FileEditorFile): void {
model.receiveFileObservation({ status: "ready", file });
}
function observeVersion(model: FileEditorModel, version: FileEditorObservation): void {
model.receiveFileObservation(version);
}
describe("FileEditorModel", () => {
test("tracks whether the current buffer differs from persisted content", async () => {
const { model } = makeModel();
expect(model.getSnapshot().modified).toBe(false);
model.edit("two");
expect(model.getSnapshot().modified).toBe(true);
model.edit("one");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
model.edit("saved");
await model.save();
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
const { model } = makeModel();
observeFile(model, {
content: "one",
hasBom: false,
version: { ...ready(), revision: "precise-revision" },
});
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
});
test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "one",
hasBom: false,
version: { ...ready(), revision: "replacement-revision" },
});
expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" });
await model.save();
expect(session.writes).toEqual([
{
content: "local",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
expectedRevision: "replacement-revision",
},
]);
});
test("adopts an external BOM change before saving a dirty buffer", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "one",
hasBom: true,
version: { ...ready(), revision: "replacement-revision" },
});
await model.save();
expect(session.writes).toEqual([
{
content: "\uFEFFlocal",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
expectedRevision: "replacement-revision",
},
]);
});
test("conflicts when a same-content observation changes the BOM during a save", async () => {
const { model, session } = makeModel();
session.holdNextWrite();
model.edit("saved");
const save = model.save();
observeFile(model, {
content: "saved",
hasBom: true,
version: ready("2026-07-18T00:00:02.000Z", 6),
});
session.finishHeldWrite({
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: 5,
});
await save;
expect(model.getSnapshot()).toMatchObject({
status: "conflict",
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
});
});
test("ignores a settled observation that was already consumed", () => {
const { model } = makeModel();
const observation: FileEditorObservation = {
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
};
let emissions = 0;
model.subscribe(() => {
emissions += 1;
});
model.receiveFileObservation(observation);
const emissionsAfterFirstDelivery = emissions;
model.receiveFileObservation(observation);
expect(emissions).toBe(emissionsAfterFirstDelivery);
});
test("does not replay the pre-save observation while its refresh is pending", async () => {
const { model } = makeModel();
const observation: FileEditorObservation = {
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
};
model.receiveFileObservation(observation);
model.edit("saved");
await model.save();
model.receiveFileObservation(observation);
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" });
});
test("does not reload stale bytes after a write conflict", async () => {
const { model, session } = makeModel();
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 8),
};
model.edit("important local work");
await model.save();
await model.reload();
expect(model.getSnapshot()).toMatchObject({
status: "conflict",
content: "important local work",
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
});
});
test("reloads a write conflict only after refreshed bytes arrive", async () => {
const { model, session } = makeModel();
const source = new ObservationSource({
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
});
model.connectFileObservations(source);
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 4),
};
model.edit("local");
await model.save();
await model.reload();
expect(source.refreshes).toBe(1);
expect(model.getSnapshot().content).toBe("local");
source.emit({
status: "ready",
file: {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
},
});
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("abandons a deferred reload when its refresh fails", async () => {
const { model, session } = makeModel();
const source = new ObservationSource({
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
});
model.connectFileObservations(source);
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 4),
};
model.edit("local");
await model.save();
await model.reload();
source.emit({
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "File unavailable.",
});
model.edit("new local work");
source.emit({
status: "ready",
file: {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:03.000Z", 4),
},
});
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" });
});
test("keeps a newer edit modified when an older save finishes", async () => {
const { model, session } = makeModel();
session.holdNextWrite();
model.edit("saving");
const save = model.save();
model.edit("newer edit");
session.finishHeldWrite({
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: 6,
});
await save;
expect(model.getSnapshot()).toMatchObject({
status: "dirty",
content: "newer edit",
modified: true,
});
});
test("autosaves the latest edit after inactivity", async () => {
const { model, session, clock } = makeModel();
model.edit("two");
model.edit("three");
clock.fire();
await Promise.resolve();
expect(session.writes).toEqual([
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("keeps CRLF content in file form", async () => {
const { model, session } = makeModel({ content: "one\r\ntwo\r\n" });
expect(model.getSnapshot()).toMatchObject({
content: "one\r\ntwo\r\n",
lineSeparator: "\r\n",
});
model.edit("one\r\ntwo\r\nthree\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "one\r\ntwo\r\nthree\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
]);
});
test("restores a UTF-8 BOM before writing a CRLF file", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("saved\r\n");
await model.save();
model.edit("saved again\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "\uFEFFsaved\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
{
content: "\uFEFFsaved again\r\n",
expectedModifiedAt: "2026-07-18T00:00:01.000Z",
},
]);
});
test("uses the first line separator when a file mixes styles", () => {
const { model } = makeModel({ content: "one\r\ntwo\nthree\r" });
expect(model.getSnapshot().lineSeparator).toBe("\r\n");
});
test("reloads a clean editor when the disk version changes", async () => {
const { model, session } = makeModel();
session.file = {
content: "external",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
};
observeFile(model, session.file);
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
});
test("adopts the format from a clean remote refresh", async () => {
const { model, session } = makeModel({ content: "local\r\n", hasBom: true });
session.file = {
content: "remote\n",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 7) as Extract<FileVersion, { status: "ready" }>,
};
observeFile(model, session.file);
expect(model.getSnapshot().lineSeparator).toBe("\n");
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
test("applies consecutive clean disk observations", () => {
const { model } = makeModel();
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
observeFile(model, { content: "first", hasBom: false, version: firstVersion });
observeFile(model, { content: "latest", hasBom: false, version: latestVersion });
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
});
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
});
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
await model.overwrite();
expect(session.writes).toEqual([
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("keeps the local CRLF and BOM when overwriting a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
observeFile(model, {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
});
await model.overwrite();
expect(session.writes).toEqual([
{
content: "\uFEFFlocal\r\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
},
]);
});
test("reload discards a conflicted local buffer for the disk contents", async () => {
const { model, session } = makeModel();
model.edit("local");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk", hasBom: false, version: diskVersion };
observeFile(model, session.file);
await model.reload();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("adopts the remote format when reloading a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
observeFile(model, session.file);
await model.reload();
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
test("reports failed saves without losing the local buffer", async () => {
const { model, session } = makeModel();
session.nextWrite = new Error("disk full");
model.edit("important local work");
await model.save();
expect(model.getSnapshot()).toMatchObject({
status: "error",
content: "important local work",
error: "disk full",
});
});
test("a deletion conflicts with local changes and stops autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" });
clock.fire();
expect(model.getSnapshot().status).toBe("conflict");
expect(session.writes).toEqual([]);
});
test("clears a transient check error when the recovered file is unchanged", () => {
const { model } = makeModel();
observeVersion(model, {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
observeFile(model, { content: "one", hasBom: false, version: ready() });
expect(model.getSnapshot()).toMatchObject({
status: "clean",
modified: false,
observedVersion: { status: "ready" },
error: null,
});
});
test("resumes autosave when a dirty file recovers unchanged from a check error", async () => {
const { model, session, clock } = makeModel();
model.edit("local");
observeVersion(model, {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
observeFile(model, { content: "one", hasBom: false, version: ready() });
clock.fire();
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
expect(session.writes).toEqual([
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
]);
});
test("dispose cancels pending autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
model.dispose();
clock.fire();
expect(session.writes).toEqual([]);
});
test("suspends a pending autosave while close confirmation is active", async () => {
const { model, session, clock } = makeModel();
model.edit("local");
const resume = model.suspendAutosave();
clock.fire();
expect(session.writes).toEqual([]);
resume();
clock.fire();
await Promise.resolve();
expect(session.writes).toHaveLength(1);
});
test("maps conflict versions to one exhaustive callout state", () => {
const { model } = makeModel();
const snapshot = model.getSnapshot();
expect(getFileConflictCallout(snapshot)).toBeNull();
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
modified: true,
observedVersion: ready("2026-07-18T00:00:01.000Z"),
}),
).toEqual({ kind: "changed", canOverwrite: true });
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" },
}),
).toEqual({ kind: "deleted" });
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
observedVersion: {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "unreadable",
},
}),
).toEqual({ kind: "checkFailed" });
});
});
-402
View File
@@ -1,402 +0,0 @@
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error";
export type FileLineSeparator = "\n" | "\r\n" | "\r";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
lineSeparator: FileLineSeparator;
modified: boolean;
version: FileVersion;
observedVersion: FileVersion;
error: string | null;
}
export type FileConflictCallout =
| { kind: "changed"; canOverwrite: boolean }
| { kind: "deleted" }
| { kind: "checkFailed" };
export interface FileEditorFile {
content: string;
hasBom: boolean;
version: Extract<FileVersion, { status: "ready" }>;
}
export interface FileEditorSession {
write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult>;
}
export type FileEditorObservation =
| { status: "ready"; file: FileEditorFile }
| Extract<FileVersion, { status: "missing" | "error" }>;
export interface FileObservationSource {
subscribe(listener: () => void): () => void;
getObservation(): FileEditorObservation | null;
refresh(): void;
}
type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion };
export interface FileEditorClock {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
}
const systemClock: FileEditorClock = {
setTimeout(callback, delay) {
return globalThis.setTimeout(callback, delay);
},
clearTimeout(handle) {
globalThis.clearTimeout(handle);
},
};
export class FileEditorModel {
private readonly session: FileEditorSession;
private readonly clock: FileEditorClock;
private readonly listeners = new Set<() => void>();
private snapshot: FileEditorSnapshot;
private autosave: ReturnType<typeof setTimeout> | null = null;
private saveSequence = 0;
private disposed = false;
private observedWhileSaving: FileEditorObservation | null = null;
private observed: ObservedDiskState;
private lastReceivedObservation: FileEditorObservation | null = null;
private refreshObservation: (() => void) | null = null;
private reloadRequested = false;
private persistedContent: string;
private hasBom: boolean;
private unsubscribeObservationSource: (() => void) | null = null;
constructor(input: {
file: FileEditorFile;
session: FileEditorSession;
clock?: FileEditorClock;
}) {
this.session = input.session;
this.clock = input.clock ?? systemClock;
this.persistedContent = input.file.content;
this.hasBom = input.file.hasBom;
this.observed = { status: "ready", file: input.file };
this.snapshot = {
status: "clean",
content: input.file.content,
lineSeparator: detectLineSeparator(input.file.content),
modified: false,
version: input.file.version,
observedVersion: input.file.version,
error: null,
};
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileEditorSnapshot => this.snapshot;
connectFileObservations(source: FileObservationSource): void {
this.disconnectFileObservations();
this.refreshObservation = source.refresh;
const receiveObservation = () => {
const observation = source.getObservation();
if (observation) this.receiveFileObservation(observation);
};
this.unsubscribeObservationSource = source.subscribe(receiveObservation);
receiveObservation();
}
disconnectFileObservations(): void {
this.unsubscribeObservationSource?.();
this.unsubscribeObservationSource = null;
this.refreshObservation = null;
}
edit(content: string): void {
if (this.disposed || content === this.snapshot.content) return;
this.reloadRequested = false;
const modified = content !== this.persistedContent;
let status: FileEditorStatus = modified ? "dirty" : "clean";
if (this.snapshot.status === "conflict") {
status = "conflict";
}
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
async save(): Promise<void> {
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
return;
}
if (this.snapshot.observedVersion.status !== "ready") {
this.enterConflict(this.snapshot.observedVersion);
return;
}
await this.performWrite(this.snapshot.observedVersion);
}
receiveFileObservation(observation: FileEditorObservation): void {
if (this.disposed || observation === this.lastReceivedObservation) return;
this.lastReceivedObservation = observation;
const version = observationVersion(observation);
this.observed = observation;
this.setSnapshot({ ...this.snapshot, observedVersion: version });
if (this.snapshot.status === "saving") {
this.observedWhileSaving = observation;
return;
}
if (observation.status !== "ready") {
this.reloadRequested = false;
this.enterConflict(version);
return;
}
if (this.reloadRequested) {
this.reloadRequested = false;
this.applyFile(observation.file);
return;
}
if (observation.file.content === this.persistedContent) {
this.adoptUnchangedFile(observation.file);
return;
}
if (this.snapshot.status === "clean") {
this.applyFile(observation.file);
return;
}
this.enterConflict(version);
}
async overwrite(): Promise<void> {
if (this.disposed || this.snapshot.status !== "conflict") return;
if (this.snapshot.observedVersion.status !== "ready") return;
await this.performWrite(this.snapshot.observedVersion);
}
async reload(): Promise<void> {
if (this.disposed) return;
if (this.observed.status !== "ready") {
this.reloadRequested = true;
this.refreshObservation?.();
return;
}
this.applyFile(this.observed.file);
}
dispose(): void {
this.disposed = true;
this.reloadRequested = false;
this.saveSequence += 1;
this.clearAutosave();
this.disconnectFileObservations();
this.listeners.clear();
}
suspendAutosave(): () => void {
const wasScheduled = this.autosave !== null;
this.clearAutosave();
let resumed = false;
return () => {
if (resumed || this.disposed) return;
resumed = true;
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
};
}
private async performWrite(
expectedVersion: Extract<FileVersion, { status: "ready" }>,
): Promise<void> {
this.clearAutosave();
const sequence = ++this.saveSequence;
const content = this.snapshot.content;
const hasBom = this.hasBom;
this.observedWhileSaving = null;
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
const serializedContent = hasBom ? `\uFEFF${content}` : content;
let result: FileWriteResult;
try {
result = await this.session.write({
content: serializedContent,
expectedModifiedAt: expectedVersion.modifiedAt,
expectedRevision: expectedVersion.revision,
});
} catch (error) {
if (this.disposed || sequence !== this.saveSequence) return;
this.setSnapshot({
...this.snapshot,
status: "error",
error: error instanceof Error ? error.message : String(error),
});
return;
}
if (this.disposed || sequence !== this.saveSequence) return;
if (result.status === "error") {
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
return;
}
if (result.status === "conflict") {
this.observed = { status: "unsettled", version: result.version };
this.enterConflict(result.version);
return;
}
const writtenVersion: Extract<FileVersion, { status: "ready" }> = {
status: "ready",
cwd: this.snapshot.version.cwd,
path: this.snapshot.version.path,
size: result.size,
modifiedAt: result.modifiedAt,
revision: result.revision,
};
const pending = this.takeObservedWhileSaving();
this.persistedContent = content;
if (pending && !observationMatchesWrite(pending, content, hasBom)) {
const pendingVersion = observationVersion(pending);
this.observed = pending;
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
version: writtenVersion,
observedVersion: pendingVersion,
error: null,
});
return;
}
const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion;
this.observed = pending ?? { status: "unsettled", version: writtenVersion };
const modified = this.snapshot.content !== this.persistedContent;
this.setSnapshot({
...this.snapshot,
status: modified ? "dirty" : "clean",
modified,
version: settledVersion,
observedVersion: settledVersion,
error: null,
});
if (modified) this.scheduleAutosave();
}
private applyFile(file: FileEditorFile): void {
this.clearAutosave();
this.saveSequence += 1;
this.persistedContent = file.content;
this.hasBom = file.hasBom;
this.observed = { status: "ready", file };
this.setSnapshot({
status: "clean",
content: file.content,
lineSeparator: detectLineSeparator(file.content),
modified: false,
version: file.version,
observedVersion: file.version,
error: null,
});
}
private takeObservedWhileSaving(): FileEditorObservation | null {
const observation = this.observedWhileSaving;
this.observedWhileSaving = null;
return observation;
}
private enterConflict(version: FileVersion): void {
this.clearAutosave();
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
observedVersion: version,
error: version.status === "error" ? version.error : null,
});
}
private adoptUnchangedFile(file: FileEditorFile): void {
this.hasBom = file.hasBom;
this.observed = { status: "ready", file };
const modified = this.snapshot.content !== this.persistedContent;
const recovering = this.snapshot.status === "conflict";
let status = this.snapshot.status;
if (recovering) status = modified ? "dirty" : "clean";
this.setSnapshot({
...this.snapshot,
status,
modified,
version: file.version,
observedVersion: file.version,
error: recovering ? null : this.snapshot.error,
});
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
private scheduleAutosave(): void {
this.clearAutosave();
this.autosave = this.clock.setTimeout(() => {
this.autosave = null;
void this.save();
}, 800);
}
private clearAutosave(): void {
if (!this.autosave) return;
this.clock.clearTimeout(this.autosave);
this.autosave = null;
}
private setSnapshot(snapshot: FileEditorSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null {
if (snapshot.status !== "conflict") return null;
switch (snapshot.observedVersion.status) {
case "ready":
return { kind: "changed", canOverwrite: snapshot.modified };
case "missing":
return { kind: "deleted" };
case "error":
return { kind: "checkFailed" };
default:
return assertNever(snapshot.observedVersion);
}
}
function assertNever(value: never): never {
throw new Error(`Unexpected file version: ${JSON.stringify(value)}`);
}
function detectLineSeparator(content: string): FileLineSeparator {
for (let index = 0; index < content.length; index += 1) {
const character = content.charCodeAt(index);
if (character === 10) return "\n";
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
}
return "\n";
}
function observationVersion(observation: FileEditorObservation): FileVersion {
return observation.status === "ready" ? observation.file.version : observation;
}
function observationMatchesWrite(
observation: FileEditorObservation,
content: string,
hasBom: boolean,
): boolean {
return (
observation.status === "ready" &&
observation.file.content === content &&
observation.file.hasBom === hasBom
);
}
@@ -1,60 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { describe, expect, test } from "vitest";
import type { LiveFileObservation } from "../live-file/model";
import { createFileObservationSource, type LiveObservationSource } from "./observation-source";
class TestLiveObservationSource implements LiveObservationSource {
observation: LiveFileObservation | null;
constructor(observation: LiveFileObservation | null) {
this.observation = observation;
}
subscribe(): () => void {
return () => undefined;
}
getObservation(): LiveFileObservation | null {
return this.observation;
}
refresh(): void {}
}
function readyObservation(): LiveFileObservation {
const file: FileReadResult = {
bytes: new TextEncoder().encode("one"),
mime: "text/plain",
size: 3,
path: "file.ts",
kind: "text",
modifiedAt: "2026-07-18T00:00:00.000Z",
revision: "revision-1",
};
return {
status: "ready",
file,
version: {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
};
}
describe("createFileObservationSource", () => {
test("returns the same editor observation until the live observation changes", () => {
const liveFile = new TestLiveObservationSource(readyObservation());
const source = createFileObservationSource(liveFile);
const first = source.getObservation();
const repeated = source.getObservation();
expect(repeated).toBe(first);
liveFile.observation = readyObservation();
expect(source.getObservation()).not.toBe(first);
});
});
@@ -1,50 +0,0 @@
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { LiveFileObservation } from "../live-file/model";
import type { FileEditorObservation, FileObservationSource } from "./model";
export interface LiveObservationSource {
subscribe(listener: () => void): () => void;
getObservation(): LiveFileObservation | null;
refresh(): void;
}
export function createFileObservationSource(
liveFile: LiveObservationSource,
): FileObservationSource {
let liveObservation: LiveFileObservation | null = null;
let editorObservation: FileEditorObservation | null = null;
return {
subscribe: liveFile.subscribe,
refresh: liveFile.refresh,
getObservation() {
const nextLiveObservation = liveFile.getObservation();
if (nextLiveObservation === liveObservation) return editorObservation;
liveObservation = nextLiveObservation;
editorObservation = fileEditorObservation(nextLiveObservation);
return editorObservation;
},
};
}
function fileEditorObservation(
observation: LiveFileObservation | null,
): FileEditorObservation | null {
if (!observation || observation.status !== "ready") return observation;
const decodedFile = explorerFileFromReadResult(observation.file);
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
return {
status: "error",
cwd: observation.version.cwd,
path: observation.version.path,
error: "File is no longer text.",
};
}
return {
status: "ready",
file: {
content: decodedFile.content,
hasBom: decodedFile.hasBom,
version: observation.version,
},
};
}
@@ -1,61 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { LiveFileModel, type LiveFileSession } from "./model";
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
import { isWeb } from "@/constants/platform";
export function useLiveFile(input: {
client: DaemonClient | null;
cwd: string | null;
path: string | null;
enabled: boolean;
liveUpdates: boolean;
}) {
const [model] = useState(() => new LiveFileModel());
const session = useMemo<LiveFileSession | null>(() => {
if (!input.client) return null;
const client = input.client;
return {
subscribe(target, onVersion) {
return client.subscribeFile(target, onVersion);
},
read(target) {
return client.readFile(
target.cwd,
target.path,
undefined,
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
);
},
};
}, [input.client]);
useEffect(() => {
if (!input.enabled || !session || !input.cwd || !input.path) {
model.close();
return;
}
model.open({
session,
target: { cwd: input.cwd, path: input.path },
liveUpdates: input.liveUpdates,
});
return () => model.close();
}, [input.cwd, input.enabled, input.liveUpdates, input.path, model, session]);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
return {
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
error: snapshot.read.status === "error" ? snapshot.read.error : null,
// Effects start reads after the first render. An enabled target is therefore
// already in the producer's initial/retargeting display state.
isFetching:
input.enabled &&
(snapshot.read.status === "pending" ||
(snapshot.read.status === "idle" && snapshot.observation === null)),
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
refresh: model.refresh,
model,
snapshot,
};
}
@@ -1,344 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import { describe, expect, it, vi } from "vitest";
import { LiveFileModel, type LiveFileSession, type LiveFileSubscription } from "./model";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function ready(revision = "revision-1"): Extract<FileVersion, { status: "ready" }> {
return {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: 3,
modifiedAt: "2026-07-30T00:00:00.000Z",
revision,
};
}
function file(revision = "revision-1", content = "one"): FileReadResult {
return {
bytes: new TextEncoder().encode(content),
mime: "text/plain",
size: content.length,
path: "file.ts",
kind: "text",
modifiedAt: "2026-07-30T00:00:00.000Z",
revision,
};
}
class TestLiveFileSession implements LiveFileSession {
readonly reads: Deferred<FileReadResult>[] = [];
subscribeAttempts = 0;
private onVersion: ((version: FileVersion) => void) | null = null;
private readonly initial: FileVersion;
constructor(initial: FileVersion = ready()) {
this.initial = initial;
}
async subscribe(
_target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
this.subscribeAttempts += 1;
this.onVersion = onVersion;
return { initial: this.initial, unsubscribe: () => (this.onVersion = null) };
}
read(): Promise<FileReadResult> {
const request = deferred<FileReadResult>();
this.reads.push(request);
return request.promise;
}
emit(version: FileVersion): void {
this.onVersion?.(version);
}
}
class DeferredSubscribeSession implements LiveFileSession {
readonly reads: Deferred<FileReadResult>[] = [];
readonly subscription = deferred<LiveFileSubscription>();
private onVersion: ((version: FileVersion) => void) | null = null;
subscribe(
_target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
this.onVersion = onVersion;
return this.subscription.promise;
}
read(): Promise<FileReadResult> {
const request = deferred<FileReadResult>();
this.reads.push(request);
return request.promise;
}
emit(version: FileVersion): void {
this.onVersion?.(version);
}
}
class FlakySubscriptionSession extends TestLiveFileSession {
override async subscribe(
target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
if (this.subscribeAttempts === 0) {
this.subscribeAttempts += 1;
throw new Error("Connection closed.");
}
return super.subscribe(target, onVersion);
}
}
async function waitForRead(
session: { reads: Deferred<FileReadResult>[] },
count: number,
): Promise<void> {
await vi.waitFor(() => expect(session.reads).toHaveLength(count));
}
describe("LiveFileModel", () => {
it("reports initial and read-pending work before any terminal observation", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
expect(model.getSnapshot()).toEqual({
observation: null,
read: { status: "pending", requested: false },
});
});
it("reports read error as terminal and recovers on refresh", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
session.reads[0].reject(new Error("read failed"));
await vi.waitFor(() =>
expect(model.getSnapshot().read).toEqual({ status: "error", error: "read failed" }),
);
model.refresh();
await waitForRead(session, 2);
session.reads[1].resolve(file("revision-2"));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
});
it("does not publish a missing candidate when the following read succeeds", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
const publishedStatuses: string[] = [];
model.subscribe(() => {
const observation = model.getObservation();
if (observation) publishedStatuses.push(observation.status);
});
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
await waitForRead(session, 2);
session.reads[1].resolve(file());
await vi.waitFor(() => expect(model.getSnapshot().read).toEqual({ status: "idle" }));
expect(publishedStatuses).not.toContain("missing");
});
it("publishes missing when the following read also fails", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
await waitForRead(session, 2);
session.reads[1].reject(new Error("File not found."));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
});
it("preserves a missing candidate for its queued confirming read", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
session.reads[0].resolve(file());
await waitForRead(session, 2);
session.reads[1].reject(new Error("File not found."));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
});
it("recovers only from a read begun after a missing observation", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
model.refresh();
await waitForRead(session, 2);
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
session.reads[1].resolve(file("revision-2", "two"));
await waitForRead(session, 3);
expect(model.getObservation()?.status).toBe("ready");
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
session.reads[2].resolve(file("revision-2", "two"));
await vi.waitFor(() => {
expect(model.getSnapshot()).toMatchObject({
observation: {
status: "ready",
version: { revision: "revision-2" },
file: { revision: "revision-2" },
},
read: { status: "idle" },
});
});
});
it("keeps an actionable error visible while refresh fails", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
session.emit({
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
await waitForRead(session, 2);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
session.reads[1].reject(new Error("File unavailable."));
await vi.waitFor(() => {
expect(model.getSnapshot()).toMatchObject({
observation: { status: "error", error: "Requested path is not a file" },
read: { status: "error", error: "File unavailable." },
});
});
model.refresh();
await waitForRead(session, 3);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
});
it("keeps an update that arrives before the subscription response", async () => {
const session = new DeferredSubscribeSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
session.emit(ready("revision-2"));
session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} });
await waitForRead(session, 1);
expect(model.getObservation()).toBeNull();
});
it("retries a failed subscription when the user refreshes", async () => {
const session = new FlakySubscriptionSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
expect(session.subscribeAttempts).toBe(1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
model.refresh();
await waitForRead(session, 2);
expect(session.subscribeAttempts).toBe(2);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
});
it("reads once without subscribing when live updates are disabled", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: false,
});
await waitForRead(session, 1);
expect(session.subscribeAttempts).toBe(0);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
});
it("ignores a read that completes after the model closes", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
model.close();
session.reads[0].resolve(file());
await Promise.resolve();
expect(model.getSnapshot()).toEqual({
observation: null,
read: { status: "idle" },
});
});
});
@@ -1,272 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
export interface LiveFileTarget {
cwd: string;
path: string;
}
export interface LiveFileSubscription {
initial: FileVersion;
unsubscribe(): void;
}
export interface LiveFileSession {
subscribe(
target: LiveFileTarget,
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription>;
read(target: LiveFileTarget): Promise<FileReadResult>;
}
export type LiveFileReadState =
| { status: "idle" }
| { status: "pending"; requested: boolean }
| { status: "error"; error: string };
export type LiveFileObservation =
| {
status: "ready";
version: Extract<FileVersion, { status: "ready" }>;
file: FileReadResult;
}
| Extract<FileVersion, { status: "missing" | "error" }>;
export interface LiveFileSnapshot {
observation: LiveFileObservation | null;
read: LiveFileReadState;
}
const idleSnapshot: LiveFileSnapshot = {
observation: null,
read: { status: "idle" },
};
export class LiveFileModel {
private readonly listeners = new Set<() => void>();
private snapshot: LiveFileSnapshot = idleSnapshot;
private session: LiveFileSession | null = null;
private target: LiveFileTarget | null = null;
private liveUpdates = false;
private unsubscribe: (() => void) | null = null;
private subscriptionPending = false;
private generation = 0;
private issuedReadSequence = 0;
private activeReadSequence: number | null = null;
private activeReadRequested = false;
private observedAfterReadSequence: number | null = null;
private candidateVersion: FileVersion | null = null;
private refreshQueued = false;
private requestedRefreshQueued = false;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): LiveFileSnapshot => this.snapshot;
getObservation = (): LiveFileObservation | null => this.snapshot.observation;
open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void {
this.reset();
this.session = input.session;
this.target = input.target;
this.liveUpdates = input.liveUpdates;
const generation = this.generation;
if (!input.liveUpdates) {
this.scheduleRead(false);
return;
}
void this.startSubscription(generation);
}
close(): void {
this.reset();
}
refresh = (): void => {
if (!this.session || !this.target) return;
if (this.liveUpdates && !this.unsubscribe && !this.subscriptionPending) {
void this.startSubscription(this.generation);
}
this.scheduleRead(true);
};
private scheduleRead(requested: boolean): void {
if (this.activeReadSequence !== null) {
this.refreshQueued = true;
this.activeReadRequested ||= requested;
this.requestedRefreshQueued ||= requested;
if (requested && this.snapshot.read.status === "pending") {
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested: true } });
}
return;
}
this.startRead(requested);
}
private reset(): void {
this.generation += 1;
this.unsubscribe?.();
this.unsubscribe = null;
this.session = null;
this.target = null;
this.liveUpdates = false;
this.subscriptionPending = false;
this.issuedReadSequence = 0;
this.activeReadSequence = null;
this.activeReadRequested = false;
this.observedAfterReadSequence = null;
this.candidateVersion = null;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
this.setSnapshot(idleSnapshot);
}
private async startSubscription(generation: number): Promise<void> {
const session = this.session;
const target = this.target;
if (!session || !target) return;
this.subscriptionPending = true;
let receivedUpdate = false;
try {
const subscription = await session.subscribe(target, (version) => {
if (generation !== this.generation) return;
receivedUpdate = true;
this.observeVersion(version);
});
if (generation !== this.generation) {
subscription.unsubscribe();
return;
}
this.subscriptionPending = false;
this.unsubscribe = subscription.unsubscribe;
if (!receivedUpdate) this.observeVersion(subscription.initial);
} catch {
if (generation !== this.generation) return;
this.subscriptionPending = false;
this.scheduleRead(false);
}
}
private observeVersion(version: FileVersion): void {
this.observedAfterReadSequence = this.issuedReadSequence;
this.candidateVersion = version;
this.scheduleRead(false);
}
private startRead(requested: boolean): void {
const session = this.session;
const target = this.target;
if (!session || !target) return;
const generation = this.generation;
const sequence = ++this.issuedReadSequence;
this.activeReadSequence = sequence;
this.activeReadRequested = requested;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested } });
void session.read(target).then(
(file) => this.finishRead({ generation, sequence, target, file }),
(error) => this.failRead({ generation, sequence, target, error }),
);
}
private finishRead(input: {
generation: number;
sequence: number;
target: LiveFileTarget;
file: FileReadResult;
}): void {
if (!this.isActiveRead(input.generation, input.sequence)) return;
const requested = this.activeReadRequested;
this.activeReadSequence = null;
this.activeReadRequested = false;
if (this.readPredatesLatestObservation(input.sequence)) {
this.startRead(requested || this.takeQueuedRequested());
return;
}
this.observedAfterReadSequence = null;
this.candidateVersion = null;
this.setSnapshot({
observation: {
status: "ready",
file: input.file,
version: readyVersion(input.target, input.file),
},
read: { status: "idle" },
});
this.startQueuedRead();
}
private failRead(input: {
generation: number;
sequence: number;
target: LiveFileTarget;
error: unknown;
}): void {
if (!this.isActiveRead(input.generation, input.sequence)) return;
const requested = this.activeReadRequested;
this.activeReadSequence = null;
this.activeReadRequested = false;
if (this.readPredatesLatestObservation(input.sequence)) {
this.startRead(requested || this.takeQueuedRequested());
return;
}
const message = errorMessage(input.error);
const candidate = this.candidateVersion;
this.candidateVersion = null;
const candidateDescribesFailure =
candidate?.status === "missing" || candidate?.status === "error";
const observation: LiveFileObservation = candidateDescribesFailure
? candidate
: { status: "error", cwd: input.target.cwd, path: input.target.path, error: message };
this.setSnapshot({ ...this.snapshot, observation, read: { status: "error", error: message } });
this.startQueuedRead();
}
private isActiveRead(generation: number, sequence: number): boolean {
return generation === this.generation && sequence === this.activeReadSequence;
}
private readPredatesLatestObservation(sequence: number): boolean {
return this.observedAfterReadSequence !== null && sequence <= this.observedAfterReadSequence;
}
private startQueuedRead(): void {
if (!this.refreshQueued) return;
this.startRead(this.takeQueuedRequested());
}
private takeQueuedRequested(): boolean {
const requested = this.requestedRefreshQueued;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
return requested;
}
private setSnapshot(snapshot: LiveFileSnapshot): void {
if (snapshot === this.snapshot) return;
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
function readyVersion(
target: LiveFileTarget,
file: FileReadResult,
): Extract<FileVersion, { status: "ready" }> {
return {
status: "ready",
cwd: target.cwd,
path: target.path,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+110 -584
View File
@@ -1,194 +1,27 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState } from "react";
import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } from "@/file-panels";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { Button } from "@/components/ui/button";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useFileHandle, useFileHandleSnapshot } from "@/file-panels/file";
import { FilePanelLoading } from "@/file-panels/loading";
import { FilePanelPicker, FilePanelView, useFilePanelChoice, type FilePanel } from "@/file-panels";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
const sourceAppearance = (theme: Theme) => ({
theme: {
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
},
});
const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance);
const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface FilePreviewBodyProps {
panel: FilePanel | null;
preview: ExplorerFile | null;
mode?: "preview" | "source";
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function ReadonlySource({
preview,
filename,
location,
navigationRevision,
}: {
preview: ExplorerFile;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { t } = useTranslation();
return (
<ThemedFileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
function TooLargeSource({ size }: { size?: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
}
function FilePreviewBody({
panel,
preview,
mode,
isLoading,
isMobile: _isMobile,
location,
navigationRevision,
imagePreviewUri,
workspaceRoot,
client,
serverId,
}: FilePreviewBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
if (isLoading && !preview) {
return (
<View style={styles.centerState} testID="file-preview-loading">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
if (!preview) {
return (
<View style={styles.centerState} testID="file-preview-unsupported">
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
</View>
);
}
const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source");
if (panel && !showSource) {
return (
<FilePanelPreview
panel={panel}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
imagePreviewUri={imagePreviewUri}
/>
);
}
if (preview.kind === "text") {
return (
<ReadonlySource
preview={preview}
filename={filePath}
location={location}
navigationRevision={navigationRevision}
/>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
import { useSessionStore } from "@/stores/session-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { formatFileSize } from "@/utils/format-file-size";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { FilePanelBar } from "./bar";
/**
* The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows
* its own loading; the tab only knows the file exists, its kind, and whether the panel has
* unsaved changes.
*/
export function FilePane({
serverId,
workspaceRoot,
@@ -201,417 +34,120 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const { settings } = useAppSettings();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
const connected = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
const root = workspaceRoot.trim();
const path = location.path.trim();
const target = useMemo(
() => (path ? resolveFilePreviewReadTarget({ path, workspaceRoot: root }) : null),
[path, root],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
() =>
normalizedFilePath
? resolveFilePreviewReadTarget({
path: normalizedFilePath,
workspaceRoot: normalizedWorkspaceRoot,
})
: null,
[normalizedFilePath, normalizedWorkspaceRoot],
const absolutePath = useMemo(
() => buildAbsoluteExplorerPath({ workspaceRoot: root, entryPath: path }),
[path, root],
);
// Re-read the file when this pane becomes visible again (#445). `isActive`
// covers tab switches; active app visibility covers backgrounding and returning
// from another window after an external edit. The gate lives in isFileQueryEnabled.
// A hidden tab or a backgrounded app stops observing; observing again reports what changed.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const enabled = isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
const handle = useFileHandle({
serverId,
target,
absolutePath,
observing: isActive && isAppVisible,
});
const liveFile = useLiveFile({
client,
cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null,
enabled,
liveUpdates: supportsEditing,
});
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const previewLifecycle = useFilePreview({
const snapshot = useFileHandleSnapshot(handle);
const targetKey = target ? `${target.cwd}:${target.path}` : null;
const { panel, choice } = useFilePanelChoice({
serverId,
targetKey,
liveFileSnapshot: liveFile.snapshot,
path,
fileKind: snapshot.status === "ready" ? snapshot.kind : null,
presentable: isActive,
lineNavigation: location.lineStart ? navigationRevision : null,
});
useEffect(() => setPreviewMode("preview"), [targetKey]);
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const panel = resolveFilePreviewPanel({
preview,
path: location.path,
associations: settings.filePanelAssociations,
});
const isRenderable = preview?.kind === "text" && panel !== null;
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const canTogglePreviewMode = isRenderable && !location.lineStart;
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
const isLoading =
previewLifecycle.status === "initial" ||
previewLifecycle.status === "read_pending" ||
previewLifecycle.status === "preparing";
return (
<FilePanePresentation
panel={panel}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
onRetryRead={liveFile.refresh}
retryingRead={liveFile.isRetrying}
retryLabel={t("common.actions.retry")}
filename={getFileNameFromPath(location.path) ?? location.path}
previewMode={canTogglePreviewMode ? previewMode : undefined}
onPreviewModeChange={canTogglePreviewMode ? setPreviewMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
const [modified, setModified] = useState(false);
usePublishPanelInstanceAttributes({ modified, suspendPendingSave: handle?.holdWrites });
const panelLocation = useMemo(
() => ({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
revision: navigationRevision,
}),
[location.lineEnd, location.lineStart, navigationRevision],
);
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
}): boolean {
return Boolean(
isWeb &&
input.supportsEditing &&
input.preview?.kind === "text" &&
input.preview.size <= 1024 * 1024,
const panelTarget = useMemo(
() => ({ serverId, workspaceRoot: root, path }),
[path, root, serverId],
);
}
function FilePanePresentation({
panel,
serverId,
workspaceRoot,
client,
readTarget,
preview,
liveFile,
onRetryRead,
retryingRead,
retryLabel,
filename,
previewMode,
onPreviewModeChange,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
liveFile: LiveFileModel;
onRetryRead: () => void;
retryingRead: boolean;
retryLabel: string;
filename: string;
previewMode?: "preview" | "source";
onPreviewModeChange?: (mode: "preview" | "source") => void;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}) {
if (!client && readTarget) {
let body;
if (!connected && target) {
body = <Text style={styles.errorText}>{t("workspace.terminal.hostDisconnected")}</Text>;
} else if (!handle || snapshot.status === "loading") {
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{disconnectedMessage}</Text>
</View>
<FilePanelLoading />
</View>
);
}
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
panel={panel}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
preview={preview as TextExplorerFile}
liveFile={liveFile}
onRetryRead={onRetryRead}
retryingRead={retryingRead}
filename={filename}
mode={previewMode}
onModeChange={onPreviewModeChange}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
/>
} else if (snapshot.status === "missing" || snapshot.status === "error") {
body = (
<>
<Text style={styles.errorText}>
{snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")}
</Text>
<Button variant="outline" size="sm" onPress={handle.retry}>
{t("common.actions.retry")}
</Button>
</>
);
}
if (errorMessage) {
if (errorMessage === "File is too large to display") {
return (
<View style={styles.container} testID="workspace-file-pane">
<TooLargeSource />
</View>
);
}
} else if (!panel) {
body = (
<>
<Text style={styles.mutedText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.mutedText}>{formatFileSize(snapshot.size)}</Text>
</>
);
} else {
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Button variant="outline" size="sm" onPress={onRetryRead} loading={retryingRead}>
{retryLabel}
</Button>
</View>
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={previewMode}
onModeChange={onPreviewModeChange}
/>
) : null}
<FilePreviewBody
panel={panel}
preview={preview}
mode={previewMode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</View>
);
}
function EditableFilePane({
panel,
serverId,
workspaceRoot,
client,
cwd,
path,
preview,
liveFile,
onRetryRead,
retryingRead,
filename,
mode,
onModeChange,
isLoading,
isMobile,
location,
navigationRevision,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
cwd: string;
path: string;
preview: TextExplorerFile;
liveFile: LiveFileModel;
onRetryRead: () => void;
retryingRead: boolean;
filename: string;
mode?: "preview" | "source";
onModeChange?: (mode: "preview" | "source") => void;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const session = useMemo(
() => ({
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, ...input });
},
}),
[client, cwd, path],
);
const [model] = useState(() => {
return new FileEditorModel({
file: {
content: preview.content ?? "",
hasBom: preview.hasBom,
version: {
status: "ready",
cwd,
path,
size: preview.size,
modifiedAt: preview.modifiedAt,
revision: preview.revision,
},
},
session,
});
});
useEffect(() => {
const source = createFileObservationSource(liveFile);
model.connectFileObservations(source);
return () => model.disconnectFileObservations();
}, [liveFile, model]);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
useEffect(() => () => model.dispose(), [model]);
const handleReload = useCallback(() => {
if (!snapshot.modified) {
void model.reload();
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: t("panels.file.editor.reloadTitle"),
message: t("panels.file.editor.reloadMessage"),
confirmLabel: t("panels.file.editor.reload"),
destructive: true,
});
if (confirmed) void model.reload();
})();
}, [model, snapshot.modified, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const conflict = fileConflictAlertState({
callout: getFileConflictCallout(snapshot),
onOverwrite: handleOverwrite,
onReload: handleReload,
onRetry: onRetryRead,
retrying: retryingRead,
});
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
const renderedPreview = useMemo<ExplorerFile>(
() => ({
...preview,
content: snapshot.content,
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
modifiedAt:
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
}),
[preview, snapshot.content, snapshot.version],
);
const showSource = mode !== "preview";
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelBar
size={
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
cursor={showSource ? cursor : undefined}
vimMode={showSource ? vimMode : null}
conflict={conflict}
mode={mode}
onModeChange={onModeChange}
/>
{showSource ? (
<ThemedFileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
<FilePanelPicker choice={choice} />
<FilePanelBar choice={choice} modified={modified} />
<MountedFilePanel
key={`${panel.id}:${targetKey}`}
panel={panel}
preview={renderedPreview}
mode={mode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
file={handle.file}
location={panelLocation}
target={panelTarget}
onModifiedChange={setModified}
/>
)}
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>{body}</View>
</View>
);
}
function fileConflictAlertState(input: {
callout: FileConflictCallout | null;
onOverwrite(): void;
onReload(): void;
onRetry(): void;
retrying: boolean;
}): FileConflictAlertState | undefined {
if (!input.callout) return undefined;
if (input.callout.kind === "deleted") return { kind: "deleted" };
if (input.callout.kind === "checkFailed") {
return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry };
}
return {
kind: "changed",
canOverwrite: input.callout.canOverwrite,
onReload: input.onReload,
onOverwrite: input.onOverwrite,
};
/** One panel mount. Its tab reports unsaved changes until the panel unmounts. */
function MountedFilePanel({
panel,
file,
location,
target,
onModifiedChange,
}: {
panel: FilePanel;
file: PluginFile;
location: { lineStart?: number; lineEnd?: number; revision: number };
target: { serverId: string; workspaceRoot: string; path: string };
onModifiedChange: (modified: boolean) => void;
}) {
const [tab] = useState<PluginFileTab>(() => ({
setModified: (modified) => onModifiedChange(modified),
}));
useEffect(() => () => onModifiedChange(false), [onModifiedChange]);
return <FilePanelView panel={panel} file={file} tab={tab} location={location} target={target} />;
}
const styles = StyleSheet.create((theme) => ({
@@ -627,24 +163,14 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
emptyText: {
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
binaryMetaText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -1,23 +0,0 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import type { LiveFileSnapshot } from "../live-file/model";
import {
createFilePanePreview,
FilePreviewLifecycleModel,
type FilePreviewLifecycleSnapshot,
} from "./model";
/** Bridges the raw-file owner to the preview preparation owner. */
export function useFilePreview(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
}): FilePreviewLifecycleSnapshot {
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const { targetKey, liveFileSnapshot } = input;
useEffect(() => {
model.setSource({ targetKey, liveFileSnapshot });
}, [liveFileSnapshot, model, targetKey]);
return snapshot;
}
@@ -1,157 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { LiveFileSnapshot } from "../live-file/model";
import { describe, expect, it } from "vitest";
import {
FilePreviewLifecycleModel,
type FilePanePreview,
type FilePreviewLifecycleSnapshot,
} from "./model";
function previewFile(content: string) {
return {
kind: "text" as const,
path: "file.ts",
size: 7,
encoding: "utf-8" as const,
hasBom: false,
modifiedAt: "2026-08-20T00:00:00.000Z",
content,
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function file(revision = "revision-1", path = "file.ts"): FileReadResult {
return {
bytes: new TextEncoder().encode("preview"),
mime: "text/plain",
size: 7,
path,
kind: "text",
modifiedAt: "2026-08-20T00:00:00.000Z",
revision,
};
}
function pending(): LiveFileSnapshot {
return { observation: null, read: { status: "pending", requested: false } };
}
function completed(rawFile: FileReadResult): LiveFileSnapshot {
return {
observation: {
status: "ready",
file: rawFile,
version: {
status: "ready",
cwd: "/workspace",
path: rawFile.path,
size: rawFile.size,
modifiedAt: rawFile.modifiedAt,
revision: rawFile.revision,
},
},
read: { status: "idle" },
};
}
function source(
targetKey: string,
snapshot: LiveFileSnapshot,
): {
targetKey: string;
liveFileSnapshot: LiveFileSnapshot;
} {
return { targetKey, liveFileSnapshot: snapshot };
}
describe("FilePreviewLifecycleModel", () => {
it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const third = deferred<FilePanePreview | null>();
const preparations = [first, second, third];
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
const preview: FilePanePreview = {
file: previewFile("preview"),
imageAttachment: null,
};
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending" });
model.setSource(source("/workspace:file.ts", completed(file("one"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
first.resolve(preview);
await expectSnapshot(model, { status: "ready", preview });
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(
source("/workspace:file.ts", {
observation: {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
},
read: { status: "pending", requested: true },
}),
);
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(source("/workspace:file.ts", completed(file("two"))));
expect(model.getSnapshot()).toEqual({ status: "preparing", preview });
second.resolve(null);
await expectSnapshot(model, { status: "unsupported" });
model.setSource(source("/workspace:file.ts", completed(file("three"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
third.reject(new Error("attachment failed"));
await expectSnapshot(model, { status: "error", message: "attachment failed" });
});
it("does not publish a stale conversion after retargeting", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const model = new FilePreviewLifecycleModel((rawFile) =>
rawFile.revision === "one" ? first.promise : second.promise,
);
const nextPreview: FilePanePreview = {
file: previewFile("two"),
imageAttachment: null,
};
model.setSource(source("/workspace:file.ts", completed(file("one"))));
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
first.resolve({ file: previewFile("one"), imageAttachment: null });
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "preparing" });
second.resolve(nextPreview);
await expectSnapshot(model, { status: "ready", preview: nextPreview });
});
});
async function expectSnapshot(
model: FilePreviewLifecycleModel,
expected: FilePreviewLifecycleSnapshot,
): Promise<void> {
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(model.getSnapshot()).toEqual(expected);
}
@@ -1,190 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { ExplorerFile } from "@/stores/session-store";
import type { LiveFileSnapshot } from "../live-file/model";
export interface FilePanePreview {
file: ExplorerFile;
imageAttachment: AttachmentMetadata | null;
}
export type FilePreviewLifecycleSnapshot =
| { status: "initial" }
| { status: "read_pending"; preview?: FilePanePreview }
| { status: "preparing"; preview?: FilePanePreview }
| { status: "ready"; preview: FilePanePreview }
| { status: "error"; message: string; preview?: FilePanePreview }
| { status: "unsupported" };
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
/** Converts a completed raw read into the preview resources consumed by FilePane. */
export async function createFilePanePreview(file: FileReadResult): Promise<FilePanePreview | null> {
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
}
const imageAttachment = await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path),
});
return { file: explorerFile, imageAttachment };
}
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
export class FilePreviewLifecycleModel {
private readonly listeners = new Set<() => void>();
private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot;
private generation = 0;
private preparedSourceKey: string | null = null;
private targetKey: string | null = null;
constructor(
private readonly prepare: (file: FileReadResult) => Promise<FilePanePreview | null>,
) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void {
const targetChanged = input.targetKey !== this.targetKey;
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
if (targetChanged) {
this.targetKey = input.targetKey;
this.preparedSourceKey = null;
this.generation += 1;
}
const source = getReadySource(input);
if (source) {
if (source.key === this.preparedSourceKey) {
return;
}
this.preparedSourceKey = source.key;
const generation = ++this.generation;
this.setSnapshot({
status: "preparing",
...(retainedPreview ? { preview: retainedPreview } : {}),
});
void this.prepareSource({ file: source.file, generation, retainedPreview });
return;
}
const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot);
if (nextSnapshot.status === "initial" && retainedPreview) {
return;
}
const retainedSnapshot = retainedPreview
? { ...nextSnapshot, preview: retainedPreview }
: nextSnapshot;
if (sameNonReadyState(this.snapshot, retainedSnapshot)) {
return;
}
this.preparedSourceKey = null;
this.generation += 1;
this.setSnapshot(retainedSnapshot);
}
private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
private async prepareSource(input: {
file: FileReadResult;
generation: number;
retainedPreview?: FilePanePreview;
}): Promise<void> {
try {
const preview = await this.prepare(input.file);
if (input.generation !== this.generation) return;
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
} catch (error) {
if (input.generation !== this.generation) return;
this.setSnapshot({
status: "error",
message: errorMessage(error),
...(input.retainedPreview ? { preview: input.retainedPreview } : {}),
});
}
}
}
export function filePreviewFromLifecycle(
snapshot: FilePreviewLifecycleSnapshot,
): FilePanePreview | undefined {
return "preview" in snapshot ? snapshot.preview : undefined;
}
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
} {
const preview = filePreviewFromLifecycle(snapshot);
return preview ?? { file: null, imageAttachment: null };
}
function getReadySource(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
}): { key: string; file: FileReadResult } | null {
const observation = input.liveFileSnapshot.observation;
if (!input.targetKey || observation?.status !== "ready") {
return null;
}
const { version } = observation;
if (input.targetKey !== `${version.cwd}:${version.path}`) {
return null;
}
return {
key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`,
file: observation.file,
};
}
function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot {
if (snapshot.read.status === "error") {
return { status: "error", message: snapshot.read.error };
}
if (snapshot.read.status === "pending") {
return { status: "read_pending" };
}
if (snapshot.observation?.status === "error") {
return { status: "error", message: snapshot.observation.error };
}
if (snapshot.observation?.status === "missing") {
return { status: "error", message: "File not found" };
}
return initialSnapshot;
}
function sameNonReadyState(
current: FilePreviewLifecycleSnapshot,
next: FilePreviewLifecycleSnapshot,
): boolean {
if (current.status !== next.status) {
return false;
}
return current.status !== "error" || next.status !== "error" || current.message === next.message;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Failed to prepare preview";
}
@@ -1,141 +0,0 @@
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { selectSourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "native" });
if (presentation === "unsupported") {
return (
<View style={styles.unsupported} testID="file-source-too-large">
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
</View>
);
}
return (
<VirtualizedSource
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
/>
);
}
function VirtualizedSource({
content,
filename,
location,
navigationRevision,
presentation,
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
if (presentation === "highlighted")
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
useEffect(() => {
if (!location.lineStart) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lines.length - 1),
animated: false,
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
}
function sourceLineKey(line: SourceLine): string {
return String(line.number);
}
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
unsupported: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { useAppSettings } from "@/hooks/use-settings";
export interface FilePanelAssociations {
/** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */
associations: Readonly<Record<string, string>>;
/** Writes the association for a selector key, or forgets it with null. */
remember(selectorKey: string, panelId: string | null): void;
}
/** The one reader and writer of the device's file panel associations. */
export function useFilePanelAssociations(): FilePanelAssociations {
const { settings, updateSettings } = useAppSettings();
const remember = useCallback(
(selectorKey: string, panelId: string | null) => {
void updateSettings((current) => ({
filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId),
}));
},
[updateSettings],
);
return { associations: settings.filePanelAssociations, remember };
}
function withAssociation(
associations: Readonly<Record<string, string>>,
selectorKey: string,
panelId: string | null,
): Record<string, string> {
const next = { ...associations };
if (panelId === null) delete next[selectorKey];
else next[selectorKey] = panelId;
return next;
}
+53 -51
View File
@@ -1,92 +1,94 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { useCallback, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StyleSheet } from "react-native-unistyles";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { Theme } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { FileHtmlPreview } from "./html-preview";
import { FilePanelLoading } from "./loading";
import { FileMarkdownPreview } from "./markdown-preview";
import type { builtinFilePanels } from "./catalog";
import type { BuiltinFilePanelProps } from "./panels";
import { SourcePanel } from "./source/panel";
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
interface FilePanelPreviewProps {
content: string;
filePath: string;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
imagePreviewUri: string | null;
}
function MarkdownPanel(props: FilePanelPreviewProps) {
/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
const text = useFileText(file);
const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
const [rendered, setRendered] = useState(false);
const handleLayout = useCallback(() => setRendered(true), []);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
return (
<View style={styles.previewScrollContainer}>
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
<FileMarkdownPreview
source={props.content}
filePath={props.filePath}
workspaceRoot={props.workspaceRoot}
client={props.client}
serverId={props.serverId}
/>
<View style={styles.container}>
<ScrollView style={styles.container} showsVerticalScrollIndicator>
<View onLayout={handleLayout}>
<FileMarkdownPreview
source={text.text}
filePath={target.path}
workspaceRoot={target.workspaceRoot}
client={client}
serverId={target.serverId}
/>
</View>
</ScrollView>
{rendered ? null : <FilePanelLoading />}
</View>
);
}
function HtmlPanel({ content }: FilePanelPreviewProps) {
function HtmlPanel({ file }: BuiltinFilePanelProps) {
const text = useFileText(file);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={content} testID="file-html-preview" />
<View style={styles.container}>
<FileHtmlPreview html={text.text} testID="file-html-preview" />
</View>
);
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ImagePanel({ file }: BuiltinFilePanelProps) {
const url = useFileUrl(file);
if (url.status === "loading") return <FilePanelLoading />;
if (url.status === "error") return <FilePanelError message={url.error.message} />;
return <ZoomableImage uri={url.url} testID="image-file-preview" />;
}
function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) {
const { t } = useTranslation();
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
function FilePanelError({ message }: { message: string }) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{message}</Text>
</View>
);
}
const components = {
"builtin:markdown": MarkdownPanel,
"builtin:html": HtmlPanel,
"builtin:image": ImagePanel,
"builtin:source": SourcePanel,
};
export function FilePanelPreview({
export function BuiltinFilePanelView({
panel,
...props
}: FilePanelPreviewProps & {
panel: (typeof builtinFilePanels)[number];
}) {
}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
const Component = components[panel.id];
return <Component {...props} />;
}
const styles = StyleSheet.create((theme) => ({
previewScrollContainer: { flex: 1, minHeight: 0 },
previewContent: { flex: 1, minHeight: 0 },
container: { flex: 1, minHeight: 0 },
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
+52 -64
View File
@@ -1,86 +1,74 @@
import { describe, expect, it } from "vitest";
import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
import { resolveFilePanel } from "./resolve";
import { filePanelCatalog } from "./catalog";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
function filePreviewRenderKind(path: string) {
const { panel } = resolveFilePanel({
file: { path, kind: "text", size: 100, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
});
return panel ? panel.id.slice("builtin:".length) : null;
const builtins = filePanelCatalog([]);
const richMarkdown: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md"] },
};
function textFile(path: string): FilePanelFile {
return { path, kind: "text" };
}
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
expect(isRenderedMarkdownFile("README.md")).toBe(true);
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
});
function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).candidates.map(
(candidate) => candidate.id,
);
}
it("detects .markdown files", () => {
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
});
it("does not treat .mdx files as rendered markdown", () => {
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
});
it("does not treat other text files as rendered markdown", () => {
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
expect(isRenderedMarkdownFile("plan.html")).toBe(false);
});
});
describe("filePreviewRenderKind", () => {
it("maps each renderable extension to its kind", () => {
expect(filePreviewRenderKind("README.md")).toBe("markdown");
expect(filePreviewRenderKind("notes.markdown")).toBe("markdown");
expect(filePreviewRenderKind("plan.html")).toBe("html");
expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html");
expect(filePreviewRenderKind("plan.htm")).toBe("html");
});
it("returns null for files without a rendered preview", () => {
expect(filePreviewRenderKind("src/index.ts")).toBe(null);
expect(filePreviewRenderKind("page.mdx")).toBe(null);
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
});
});
function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null;
}
describe("built-in file panels", () => {
it("opens each rendered extension with its panel and Source after it", () => {
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
expect(panelId(textFile("plan.htm"))).toBe("builtin:html");
});
it("opens every other text file with Source, whatever its name", () => {
for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) {
expect(candidateIds(textFile(path))).toEqual(["builtin:source"]);
}
});
it("puts plugin panels after the rendering built-ins and before Source", () => {
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
"builtin:markdown",
richMarkdown.id,
"builtin:source",
]);
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
});
it("has no Source for files that are not text", () => {
expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]);
});
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
"registers the image extension %s",
(extension) => {
const resolution = resolveFilePanel({
file: {
path: `/capture.${extension.toUpperCase()}`,
kind: "image",
size: 512,
hasBytes: true,
},
candidates: builtinFilePanels,
file: { path: `/capture.${extension.toUpperCase()}`, kind: "image" },
candidates: builtins,
associations: { [`ext:${extension}`]: "builtin:markdown" },
});
expect(resolution.panel?.id).toBe("builtin:image");
expect(
resolveFilePanel({
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
candidates: builtinFilePanels,
file: { path: "capture.png", kind: "binary" },
candidates: builtins,
associations: {},
}).panel,
).toBeNull();
},
);
it("does not impose a new preview size limit on built-in text panels", () => {
expect(
resolveFilePanel({
file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
}).panel?.id,
).toBe("builtin:markdown");
});
});
+31 -16
View File
@@ -1,28 +1,43 @@
import { filePanelExtension, type FilePanelCandidate } from "./resolve";
import type { FilePanelCandidate } from "./resolve";
const markdownPanel = {
id: "builtin:markdown",
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
} as const satisfies FilePanelCandidate;
export const builtinFilePanels = [
markdownPanel,
const renderingPanels = [
{
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
fileKind: "text",
},
{
id: "builtin:html",
builtin: true,
files: { extensions: ["html", "htm"] },
document: "text",
maxBytes: Infinity,
fileKind: "text",
},
{
id: "builtin:image",
builtin: true,
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
document: "bytes",
maxBytes: Infinity,
fileKind: "image",
},
] as const satisfies readonly FilePanelCandidate[];
export function isRenderedMarkdownFile(path: string): boolean {
return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path));
/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
export const sourceFilePanel = {
id: "builtin:source",
builtin: true,
files: "all",
fileKind: "text",
} as const satisfies FilePanelCandidate;
export type RenderingFilePanel = (typeof renderingPanels)[number];
export type SourceFilePanel = typeof sourceFilePanel;
/**
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
* panels, then Source. Source is last so it is the default only when nothing else claims the file.
*/
export function filePanelCatalog<Plugin extends FilePanelCandidate>(
pluginPanels: readonly Plugin[],
): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] {
return [...renderingPanels, ...pluginPanels, sourceFilePanel];
}
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { useFilePanelAssociations } from "../associations";
import { sourceFilePanel } from "../catalog";
import { useFilePanels, type FilePanel } from "../panels";
import { resolveFilePanel, type FilePanelFileKind } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry";
export type FilePanelChoice = FilePanelChoiceModel<FilePanel>;
/**
* The file tab's panel: resolves it from the catalog, the device associations and this tab's
* choice, and owns the picker and "Open with…" for it.
*/
export function useFilePanelChoice(input: {
serverId: string;
targetKey: string | null;
path: string;
/** The kind the file's stat reports, or null before the stat. */
fileKind: FilePanelFileKind | null;
/** The tab is on screen. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}): { panel: FilePanel | null; choice: FilePanelChoice } {
const { targetKey, path, fileKind, presentable, lineNavigation } = input;
const { serverId, workspaceId, tabId } = usePaneContext();
const candidates = useFilePanels(input.serverId);
const { associations, remember } = useFilePanelAssociations();
const [choice] = useState(
() => new FilePanelChoiceModel<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
);
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
() =>
fileKind
? resolveFilePanel({
file: { path, kind: fileKind },
candidates,
associations,
choice: chosenPanelId,
})
: null,
[associations, candidates, chosenPanelId, fileKind, path],
);
useEffect(() => {
choice.observe({ targetKey, resolution, presentable, lineNavigation });
}, [choice, lineNavigation, presentable, resolution, targetKey]);
useEffect(
() => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice),
[choice, serverId, tabId, workspaceId],
);
return { panel: resolution?.panel ?? null, choice };
}
const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot<FilePanel> = {
targetKey: null,
chosenPanelId: null,
picker: null,
openWith: null,
};
const subscribeNothing = () => () => {};
const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT;
/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */
export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null {
const { serverId, workspaceId, tabId } = identity;
const getSnapshot = useCallback(
() => getFilePanelChoice({ serverId, workspaceId, tabId }),
[serverId, tabId, workspaceId],
);
return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot);
}
export function useFilePanelChoiceSnapshot(
choice: FilePanelChoice | null,
): FilePanelChoiceSnapshot<FilePanel> {
return useSyncExternalStore(
choice?.subscribe ?? subscribeNothing,
choice?.getSnapshot ?? getNoChoiceSnapshot,
choice?.getSnapshot ?? getNoChoiceSnapshot,
);
}

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