Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 0d61cca50c fix(app): identify Android certificate failures as TLS errors (#7429)
Recognize Android certificate-path validation failures before the generic not-found classification, so Direct connection no longer tells users to debug DNS for an untrusted certificate. Keep the existing TLS copy and technical details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 00:53:37 +02:00
paseo-bot[bot] 0eee4fd9b0 docs(schedules): explain archive-on-finish behavior (#7426)
The schedule and worktree guides omitted the existing retention control and its default. Document the app setting and clarify that CLI and MCP tools do not expose it.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 23:56:49 +02:00
paseo-bot[bot]andPete Keen 69ef741a73 fix(app): pause native status rings in hidden panels (#7423)
Retained panels keep their children mounted, so mount-only registration kept hidden rings subscribed to the shared UI clock. Detach inactive rings and retain a local phase so hidden animated styles also stop receiving updates while visible rings continue.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Pete Keen <pete@petekeen.net>
2026-10-19 23:26:01 +02:00
paseo-bot[bot] 90f3cab585 fix(nix): report the project license in package metadata (#7415)
The Apache-2.0 relicensing left both Nix derivations declaring AGPL. Match their metadata to the project license and check both packages against the root manifest on every supported Nix system.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 21:58:09 +02:00
paseo-bot[bot]andIvan Malison fc775b6db5 Load translations when their language is selected (#7410)
* perf(app): load translations when their language is selected

Eager locale imports evaluated and registered all nine translation bundles on every cold start. Load bundled locales through a synchronous i18next backend so language selection keeps its before-render contract and offline behavior.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): normalize translation entry paths across platforms

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-19 21:03:24 +02:00
paseo-bot[bot]anddwyanewang 71e7cf6ff8 Build native Android modules with the project NDK (#7403)
* fix(android): align Unistyles NDK version

* fix(android): use the project NDK for every native module

The diff module still selected the Android Gradle default NDK, causing a second installation after Unistyles was aligned. Inherit the project toolchain and check all configured native projects.

---------

Co-authored-by: dwyanewang <908280968@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 20:09:07 +02:00
paseo-bot[bot]andMichael Wu eda3e4318f Clarify and localize provider usage (#7376)
* Localize provider usage UI

* fix: restore compact provider usage meter

* fix: localize compact context percentage

* fix(app): deduplicate context meter refresh

* fix(app): localize provider usage time counts

* fix(app): stabilize compact provider usage row

* fix(app): localize provider usage plurals

* test(app): allow locale-specific plural keys

* fix(app): show compact quota without context usage

* fix(app): preserve context overflow percentage

* fix(app): truncate provider balance reset values

* fix(app): add Korean provider usage translations

* fix(app): refresh provider usage timing

* fix(app): localize provider usage labels

* fix(app): clarify compact usage policies

* fix(app): localize monthly provider usage

* test(app): require Arabic provider usage plurals

* docs(i18n): keep migration guidance focused

* fix(app): hide empty compact usage slot

* fix(app): keep usage in context tooltip

* fix(app): align usage with sidebar reports

* test(app): pass usage timing clock explicitly

* fix(app): restore context usage details

* fix(app): make context usage tooltip interactive

* fix(app): support keyboard tooltip controls

* fix(app): keep focused tooltip controls open

* fix(app): address usage localization review

* fix(app): localize bulk usage refresh

* fix(app): localize Claude session window

* fix(app): complete provider usage localization

* fix(app): keep mounted usage times and translations current

Pass time and translation values into usage rendering so React Compiler invalidates cached labels when they change. Use the same localized window names and percentage meanings in sidebar summaries.

* test(desktop): wait for restored tabs before shortcut input

Document load completes before the workspace reconnects. Wait for the restored selection so Ctrl+1 reaches the mounted workspace rather than the startup screen.

* test(app): allow the shared usage clock minute phase

The browser clock advances independently of daemon timestamps, and the shared ticker samples on wall-minute boundaries. Assert the bounded one-to-two-minute age and matching countdown progress instead of a single phase-dependent value.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 18:01:45 +02:00
paseo-bot[bot]andgszep 91154ba4e8 fix(opencode): advertise agent-scoped bridge tool schemas (#7383)
Co-authored-by: gszep <contact@gszep.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 16:58:21 +02:00
paseo-bot[bot] 05920a236c test(codex): observe permission events in auto-review scenarios (#7382)
List snapshots do not subscribe modern clients to permission events. Activate that feed before both scenarios so the request assertion and auto-review absence assertion exercise delivery.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 15:49:47 +02:00
paseo-bot[bot] dd244c076d fix(pi): remove running rows for refused subagent resumes (#7369)
A text-only refusal finishes successfully without identifying a child. Remove its provisional subagent row when the tool completes, while retaining the refusal in the tool timeline.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 14:11:41 +02:00
Mohamed Boudra 1c8c68f93e Keep workspace setup logs scoped to the visible setup panel (#7407)
* fix(workspaces): observe setup output only in the visible setup panel

Setup logs were requested for the whole session, including unrelated workspaces. Use directory status for discovery and source-owned workspace observations for details, preserving legacy delivery only for clients without the negotiated capability.

* fix(workspaces): publish external setup progress once per daemon

MCP and scheduled setup enter through the broadcast API. Publish them to the shared setup owner before session fanout so each observation gets one update and snapshots survive periods without connected clients. Keep the setup action available on older hosts to explain the required host update.

* fix(app): replace setup details with each observation snapshot

A host restart can return an empty setup snapshot. Apply that authoritative snapshot so an open panel does not retain output from the previous daemon. Assert setup membership requests as well as delivery in the browser lifecycle regression.

* test(workspaces): cover setup status catch-up after directory release

* test(workspaces): synchronize setup output and release fixture resources
2026-10-19 18:34:53 +07:00
paseo-bot[bot] 19749baeec Keep Android settings inputs visible above the keyboard (#7362)
* fix(app): reveal Android settings inputs above the keyboard

PageLayout kept its full-height scroll viewport while the keyboard controller handled Android insets, leaving Code size covered. Use the existing keyboard-aware scroller for Android pages and cover the real focused field and keyboard geometry in the mobile regression harness.

* fix(app): keep settings field frames above the Android keyboard

Keyboard scrolling revealed only the native text input, leaving the enclosing field border and padding covered. Allow theme spacing around the input and assert the visible Plugins source frame against the real keyboard.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 13:11:20 +02:00
paseo-bot[bot] e760764b37 fix(app): require a number when recording index shortcuts (#7365)
Tab and workspace navigation need a final 1–9 index. Prevent saving an incomplete recorded sequence and explain the requirement while preserving arbitrary chord prefixes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 11:55:10 +02:00
Mohamed Boudra b6dde61a7f feat(plugins): accept inline SVG and component icons (#7368)
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 15:36:04 +07:00
Mohamed Boudra 1e0e56c721 refactor(app): unify built-in file panel selection (#7366)
* refactor(app): unify built-in file panel selection

* refactor(app): resolve file panels once per pane
2026-10-19 15:35:41 +07:00
c575464e8f Skip saving unchanged workspace layouts (#7358)
* perf(app): throttle workspace layout writes and skip no-op persists

Zustand persist calls setItem after every set, even when the updater
returns the same state. The layout store re-normalized every workspace
tree, re-validated the envelope, stringified it, and wrote to
AsyncStorage on each agent status change because reconcileTabs runs from
an effect keyed on agent visibility.

The draft store's throttled writer moves to storage/ as a generic
throttled persist storage, gains a referential short-circuit for a value
whose partialized state was already accepted, and now backs both the
draft and layout stores. The layout store's partialize reuses its
previous projection while every persisted slice is the same reference,
and the store is flushed alongside drafts when the app leaves the
foreground.

Before: 50 no-op reconciles produced 50 writes. After: 0 writes; 50 real
sets inside one interval produce 2 writes (leading and trailing).

Fixes #4814

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(app): build layout stores with options and flush before rehydrating

Six call sites still passed the id factories positionally, which this
branch replaced with an options object, so those stores silently used
default ids. The once-only PR test also rehydrated a second store before
the throttled write landed; flush the source first.

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

* fix(app): avoid shadowing layout options

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 10:02:25 +02:00
paseo-ai[bot] 9a0b95aa10 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-19 07:09:09 +00:00
paseo-bot[bot]andaz33zy 9be1547741 fix(app): show a promoted git action once in the compact menu (#7355)
The compact Changes toolbar flattens every git action into one menu, and
`secondary` keeps the actions the policy can promote to `primary`. When the
policy promoted one of them - Pull behind origin, Push ahead of it, the merge
and PR actions - it rendered twice, under the same React key.

Dedupe by id in `actionsWithoutPrimary` so the split button and the command
center share one rule, cover it in policy.test.ts against real buildGitActions
output, and pin the rendered menu in a compact Changes browser spec.

(cherry picked from commit 9bb26cc471)

Co-authored-by: az33zy <10743275+az33zy@users.noreply.github.com>
2026-10-19 08:54:23 +02:00
Mohamed Boudra cafe4b01c8 chore(release): cut 0.12.0 2026-10-19 08:45:41 +02:00
Mohamed Boudra 8dd3e2e34e docs(changelog): prepare 0.12.0 stable 2026-10-19 08:41:01 +02:00
paseo-bot[bot] b8bcd204a9 Dismiss touch composer focus when switching tabs (#7352)
* fix(app): dismiss touch composer focus when switching tabs

A retained tab can hide and return before Chromium paints, preserving the focused textarea without another focus call. Blur web touch editors when their pane becomes inactive so returning leaves the conversation ready to read. Exercise the same mounted editor through a second edit and keyboard navigation cycle.

* test(app): reproduce touch tab focus race before paint

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 08:15:20 +02:00
Mohamed Boudra d61dcaf1df fix(app): give subagent rows room on compact and standard press feedback (#7364)
The subagent track rows were as dense on a phone as in the desktop popover,
and the archive and detach actions were hand-rolled pressables that only
recoloured the glyph, so a tap or hover never showed the rounded highlight
every other icon action has.

Rows now take the compact menu-row height and padding below the `md`
breakpoint, decided once in the track row style; the desktop popover keeps
its density. The actions are `ToolbarButton`, which owns the hitbox, the
rounded highlight on hover and press, the compact size, and the tooltip.

A repo oxlint rule, `paseo/no-raw-pressable`, reports `Pressable` and
`Touchable*` imports outside `components/ui/` so the next tap target reaches
for a primitive. Existing importers are on a burn-down list they can only
leave. The rule is its own name rather than another `no-restricted-imports`
entry so its override cannot interact with the existing burn-downs. Its test
runs the real oxlint binary and is wired into the CI quality job.
2026-10-19 12:24:14 +07:00
paseo-bot[bot] 644d66ba4f Select complete Korean terminal words on long press (#7353)
* fix(terminal): select complete Korean words on long press

Wide-character continuation cells carry a blank character but occupy no extra glyph. Treat only actual cells as word separators so either half selects the complete word while real spaces still delimit it.

* fix(terminal): preserve selected words until a drag begins

An unmoved touch release contracted the word selected by the long-press timer. Only extend selection after crossing the existing drag threshold. The Android regression checks the copied whole word after the finger lifts.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 07:03:24 +02:00
206 changed files with 5788 additions and 1403 deletions
+4
View File
@@ -93,6 +93,10 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npm run lint
- name: Test lint rules
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: node --test scripts/oxlint/paseo-plugin.test.mjs
- name: Build relay
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
run: npm run build:relay
+112
View File
@@ -5,6 +5,7 @@
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
"categories": {
"correctness": "error",
"suspicious": "error",
@@ -356,6 +357,117 @@
"no-restricted-imports": "off"
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
"paseo/no-raw-pressable": "error"
}
},
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
// 94 files. They may only leave this list.
{
"files": [
"packages/app/src/components/ui/**/*.{ts,tsx}",
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/attachment-pill.tsx",
"packages/app/src/components/combined-model-selector.tsx",
"packages/app/src/components/compact-explorer-sidebar.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/desktop/window-controls.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/file-explorer-pane.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/highlighted-code-block.tsx",
"packages/app/src/components/hosts/host-filter.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/import-session-sheet.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
"packages/app/src/components/message.tsx",
"packages/app/src/components/model-browser.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
"packages/app/src/components/realtime-voice-overlay.tsx",
"packages/app/src/components/schedules/schedule-row.tsx",
"packages/app/src/components/settings/collapsible-row.tsx",
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
"packages/app/src/components/sidebar-callout.tsx",
"packages/app/src/components/sidebar-resize-handle.tsx",
"packages/app/src/components/sidebar-workspace-list.tsx",
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
"packages/app/src/components/terminal-copy-paste-actions.tsx",
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/components/workspace-hover-card.tsx",
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
"packages/app/src/composer/agent-controls/index.tsx",
"packages/app/src/composer/diff-stat-pill.tsx",
"packages/app/src/composer/dock/internal/background.native.tsx",
"packages/app/src/composer/draft/import-pill.tsx",
"packages/app/src/composer/index.tsx",
"packages/app/src/composer/tracks.tsx",
"packages/app/src/desktop/browser/pane/index.electron.tsx",
"packages/app/src/git/actions-split-button.tsx",
"packages/app/src/git/commits-section/commit-row.tsx",
"packages/app/src/git/commits-section/commits-section.tsx",
"packages/app/src/git/diff-pane.tsx",
"packages/app/src/git/forges/gitlab.view.tsx",
"packages/app/src/git/pull-request-panel/checks-section.tsx",
"packages/app/src/git/pull-request-panel/pane.tsx",
"packages/app/src/git/pull-request-panel/section-kit.tsx",
"packages/app/src/hosts/host-chooser.tsx",
"packages/app/src/mobile-panels/presentation.tsx",
"packages/app/src/panels/new-tab-panel.tsx",
"packages/app/src/panels/setup-panel.tsx",
"packages/app/src/plugins/buttons/view.tsx",
"packages/app/src/review/surface.tsx",
"packages/app/src/screens/open-project-screen.tsx",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/projects-screen.tsx",
"packages/app/src/screens/settings-screen.tsx",
"packages/app/src/screens/settings/host-appearance-section.tsx",
"packages/app/src/screens/settings/host-page.tsx",
"packages/app/src/screens/settings/providers-section.tsx",
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
"packages/app/src/usage/sidebar-item.tsx",
"packages/app/src/usage/window-bar.tsx",
"packages/app/src/workspace-labels/manager-modal.tsx",
"packages/app/src/workspace-labels/swatch.tsx",
"packages/app/src/workspace/open-in-editor/button.tsx"
],
"rules": {
"paseo/no-raw-pressable": "off"
}
},
{
"files": ["**/e2e/**/fixtures.ts"],
"rules": {
+16 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.12.0-beta.3 - 2026-10-18
## 0.12.0 - 2026-10-19
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
@@ -29,9 +29,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
@@ -46,6 +47,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
### Fixed
@@ -59,6 +61,8 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
@@ -68,6 +72,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
@@ -99,9 +104,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
@@ -158,7 +167,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
## 0.11.4 - 2026-10-15
+2 -1
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -249,6 +249,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
+27 -9
View File
@@ -240,8 +240,9 @@ 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` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
`@getpaseo/plugin/client/react-native`. 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
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
@@ -332,7 +333,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
@@ -400,6 +401,25 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
### Plugin icons
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
`icon` is a Lucide name, `{ svg }`, or a component.
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
The daemon applies it to provider and usage-source icon files; the app applies it to inline
icons. Change the rules there, never at a call site.
- The app validates each icon once, at registration, through `parsePluginIcon` in
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
- That module is the only code that branches on the icon form. Surfaces draw with
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
component icon never reach an SVG element.
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
each attachment, and a component cannot be stored.
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
markup string.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
@@ -491,15 +511,13 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
string is never inline SVG or a URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
+8
View File
@@ -74,6 +74,14 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
Workspace setup details negotiate `workspace_setup_observation` with
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
the legacy session event category. The setup panel owns a workspace-scoped observation while
active and visible; directory metadata signals failures without requesting output. Older clients
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
requires a host update to view details. Subscription-capable older hosts honor that absence of
demand; broadcast-era hosts require an update before wire silence is guaranteed.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
+1 -1
View File
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
+15
View File
@@ -45,6 +45,21 @@
}
);
checks = forAllSystems (
system:
let
pkgs = pkgsFor system;
packages = self.packages.${system};
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
in
{
license-metadata =
assert packages.paseo.meta.license.spdxId == license;
assert packages.desktop.meta.license.spdxId == license;
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
+1 -1
View File
@@ -257,7 +257,7 @@ buildNpmPackage {
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
license = lib.licenses.asl20;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
+1 -1
View File
@@ -1 +1 @@
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
+1 -1
View File
@@ -148,7 +148,7 @@ buildNpmPackage rec {
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
license = lib.licenses.asl20;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,115 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task();
}
});
async function createRemoteRepo(prefix: string): Promise<string> {
const repo = await createTempGitRepo(prefix, {
withRemote: true,
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
});
cleanupTasks.push(repo.cleanup);
// The helper puts the bare remote inside the checkout; keep it out of the status.
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
return repo.path;
}
async function registerWorkspace(repoPath: string): Promise<string> {
const client = await connectSeedClient();
cleanupTasks.push(async () => {
await client.close().catch(() => undefined);
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
}
return created.workspace.id;
}
async function openCompactActionsMenu(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const menu = page.getByTestId("changes-primary-cta-menu");
await expect(menu).toBeVisible();
return menu;
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", other]);
});
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
const workspaceId = await registerWorkspace(repoPath);
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-push-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
const workspaceId = await registerWorkspace(repoPath);
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
});
+16 -14
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import {
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
@@ -17,7 +18,6 @@ import {
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
@@ -205,19 +205,21 @@ test.describe("touch screen wide enough for the desktop layout", () => {
title: "Second touch chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await composerLocator(page).fill("Keep this touch draft");
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await switchComposerTabWithKeyboard(page, "previous");
await expectComposerDraft(page, "");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerDraft(page, "Keep this touch draft");
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, "Keep this touch draft after return");
// Repeat after an explicit edit so retained focus from an earlier visit is
// also dismissed when the same mounted editor becomes inactive again.
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
await composerLocator(page).fill(draft);
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await roundTripComposerTabBeforePaint(page, "Second touch chat");
await expectComposerDraft(page, draft);
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, `${draft} after return`);
}
} finally {
await workspace.cleanup();
}
@@ -149,7 +149,7 @@ for (const layout of [
await expect(details.getByText(/tokens$/)).toHaveCount(0);
const account = usageCard(details, "login-journey:account");
await expect(account.getByText("Session", { exact: true })).toBeVisible();
await expect(account.getByText("31%", { exact: true })).toBeVisible();
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
await qaScreenshot(page, `context-without-data-${layout.name}`);
await layout.close(page);
});
@@ -161,7 +161,7 @@ for (const layout of [
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
await expect(
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
).toBeVisible();
});
} finally {
@@ -0,0 +1,138 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "plugin-icons-e2e";
const SVG_PATH = "M4 12l5 5L20 6";
const pluginClientSource = `import React from "react";
import { Text, View } from "react-native";
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
function BadgeIcon({ size, color }) {
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function Panel() {
return <View><Text>Plugin icon panel</Text></View>;
}
const panels = [
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
];
export default function contribute(client) {
for (const panel of panels) {
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
client.addCommandCenterItem({
id: panel.id,
title: "Open " + panel.title,
icon: panel.icon,
context: "workspace",
onSelect({ openPanel }) { openPanel(panel.id); },
});
}
return () => {};
}`;
function pluginTab(page: Page, panelId: string): Locator {
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin icons", () => {
test.describe.configure({ timeout: 180_000 });
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await test.step("Command Center rows draw each icon form", async () => {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("icon panel");
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
await expect(
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
).toBeVisible();
}
await expect(
panel
.getByRole("button", { name: "Open SVG icon panel", exact: true })
.locator(`path[d="${SVG_PATH}"]`),
).toBeVisible();
await expect(
panel
.getByRole("button", { name: "Open Component icon panel", exact: true })
.getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "command-center-icons");
await page.keyboard.press("Escape");
});
await test.step("workspace panel tabs draw each icon form", async () => {
await runCommand(page, "Open Lucide icon panel");
await runCommand(page, "Open SVG icon panel");
await runCommand(page, "Open Component icon panel");
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
await expect(
pluginTab(page, "component").getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "workspace-tab-icons");
});
} finally {
try {
await client.removePlugin(PLUGIN_ID);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await rm(directory, { recursive: true, force: true });
}
}
});
});
+99 -14
View File
@@ -4,9 +4,10 @@ import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { openSettingsSection } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
@@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
await refreshAllUsage(page);
await usage.waitForListRequests(requestCount + 1);
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText("41%", { exact: true })).toBeVisible();
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
}
@@ -178,7 +179,7 @@ test.describe("usage modal", () => {
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("31% used")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -240,15 +241,15 @@ test.describe("usage modal", () => {
await gotoAppShell(page);
await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await expect
.poll(() => forcedRefreshes(usage))
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
await expect(group.getByText("58% used")).toBeVisible();
await expect(group.getByText("12% used")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
@@ -332,7 +333,7 @@ test.describe("usage modal", () => {
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-modal-picked-host");
@@ -347,7 +348,7 @@ test.describe("usage modal", () => {
await expectPinnedUsage(page, ["12% Weekly"]);
await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
@@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, 31% · resets /,
/^Pin Claude Session, 31% used · resets /,
);
await openUsageOptions(page);
await closeUsageOptions(page);
@@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
},
]);
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageOnly(page, "42% used");
} finally {
await fixture.cleanup();
}
@@ -599,6 +600,7 @@ for (const compact of [false, true]) {
}) => {
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
try {
await page.clock.install();
await page.setViewportSize(
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
@@ -615,7 +617,8 @@ for (const compact of [false, true]) {
// Move only the browser clock; real daemon fetches continue using the host clock.
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageOnly(page, "42% used");
await advanceUsageClock(page, 60_000);
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
@@ -640,8 +643,9 @@ for (const compact of [false, true]) {
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43%");
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
await expectLoginUsageOnly(page, "43% used");
await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
@@ -651,7 +655,88 @@ for (const compact of [false, true]) {
async function expectRetainedLoginUsage(page: Page, message: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
async function switchToJapanese(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await openSettings(page);
await openSettingsSection(page, "general");
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
await page.getByRole("button", { name: "戻る", exact: true }).click();
}
async function openJapaneseUsage(page: Page) {
await page.getByRole("button", { name: "使用状況", exact: true }).click();
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
}
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
test.setTimeout(120_000);
const login = await installLoginUsage({
status: "available",
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
});
try {
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
await switchToJapanese(page);
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
await openJapaneseUsage(page);
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
await page.getByRole("button", { name: "設定", exact: true }).last().click();
await expect(page.getByText("割合", { exact: true })).toBeVisible();
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
await expect(page.getByText("残り", { exact: true })).toBeVisible();
} finally {
await login.cleanup();
}
});
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
test.setTimeout(120_000);
const now = new Date();
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
const login = await installLoginUsage({
status: "available",
windows: [
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
],
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
});
try {
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets (28|29)m$/,
);
} finally {
await login.cleanup();
}
});
async function openCompactUsageWithClock(page: Page, now: Date) {
await page.setViewportSize({ width: 390, height: 844 });
await page.clock.install({ time: now });
await gotoAppShell(page);
await openCompactSidebar(page);
await openUsageFromIcon(page);
}
async function advanceUsageClock(page: Page, milliseconds: number) {
await page.clock.fastForward(milliseconds);
}
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("70% used")).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("usage-card");
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
await expect(card.getByText("64%")).toBeVisible();
await expect(card.getByText("64% used")).toBeVisible();
});
test("asks to update a host without usage support and never calls it", async ({ page }) => {
@@ -28,6 +28,7 @@ import {
closeWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
observeSetupPanelTraffic,
} from "../support/helpers/workspace-setup";
interface WorkspaceScriptStarter {
@@ -116,6 +117,45 @@ test.describe("Workspace setup streaming", () => {
}
});
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-membership-", {
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
});
const traffic = observeSetupPanelTraffic(page);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed",
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "setup-membership",
});
await completed;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
await traffic.expectNoDemand();
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 1);
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
await clickNewChat(page);
await expectComposerVisible(page);
await traffic.expectReleased(0);
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 2);
await closeSetupTab(page, workspace.id);
await traffic.expectReleased(1);
} finally {
await client.close();
await repo.cleanup();
}
});
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
@@ -0,0 +1,19 @@
# Prerequisite: a native terminal showing the output of echo '한글 문장'
# on its first row, with no active selection. Use a dedicated QA workspace.
# Coordinates target the first half of 문 on a 1080x1920 phone at default
# code size. Override WORD_X/WORD_Y for other device geometry.
context platform=android timeout=30000 retries=0
env APP_ID=sh.paseo.debug
env WORD_X=105
env WORD_Y=251
open "${APP_ID}"
wait "label=\"Terminal input\"" 10000
wait "label=\"한글 문장\"" 10000
longpress "${WORD_X}" "${WORD_Y}" 650
wait "id=\"terminal-copy\"" 5000
press "id=\"terminal-copy\""
# Copy clears the selection; only Android's clipboard preview has this exact
# text, since the unselected terminal row contains the full sentence.
wait "text=\"문장\"" 3000
close
@@ -0,0 +1,145 @@
// Run against an Android dev client serving this checkout, with a real software keyboard.
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
assert(
session && metroUrl && serverId,
"Pass the agent-device session, Metro URL and connected host ID",
);
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
}
// Read the mounted app's public React Native APIs through Metro. Accessibility
// snapshots with Gboard open can block while waiting for an idle hierarchy.
const reactNative = `Array.from(__r.getModules().values()).find(
m => m.verboseName?.endsWith("/react-native/index.js")
).publicModule.exports`;
function measureFocusedInput() {
evaluate(`(() => {
const rn = ${reactNative};
globalThis.__appearanceKeyboardMeasurement = null;
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
x, y, width, height,
statusBarHeight: rn.StatusBar.currentHeight,
scale: rn.PixelRatio.get(),
keyboard: rn.Keyboard.metrics(),
});
});
})()`);
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
return value ? JSON.parse(value) : null;
}
async function waitForFocusedInput() {
let measurement;
let previous;
let stable = 0;
const deadline = Date.now() + 30_000;
do {
measurement = measureFocusedInput();
const current = measurement?.keyboard
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
: null;
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
previous = current;
if (stable >= 3) break;
await delay(150);
} while (Date.now() < deadline);
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
return measurement;
}
ad("open", appId, "--platform", "android", "--no-test-ime");
const targets = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.filter((line) => line.includes(appId));
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
try {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Appearance", "5000");
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Code size", "5000");
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const numericFields = snapshot.data.nodes.filter(
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
);
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
// Appearance orders Interface size, Content size, then Code size.
ad("press", `@${numericFields[2].ref}`);
const measurement = await waitForFocusedInput();
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
const keyboardTop = measurement.keyboard.screenY;
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
ad("screenshot", resolve(artifacts, "keyboard.png"));
assert(
inputBottom <= keyboardTop + 1 / measurement.scale,
"Code size must be fully visible above the number keyboard (within one physical pixel)",
);
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad(
"open",
appId,
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
"--platform",
"android",
"--no-test-ime",
);
ad("wait", "text", "Plugin source", "5000");
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
assert(source, "The connected host must support installing plugins from source");
ad("press", `@${source.ref}`);
ad("type", "qa-source");
const pluginMeasurement = await waitForFocusedInput();
// A FormTextInput draws its border and padding on the native input's parent.
// Measure that visible frame, rather than accepting only the text as visible.
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
assert(focused, "Plugin source must remain focused after typing");
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
assert(frame, "The source input's visible frame must be present in the native hierarchy");
assert(
frame.rect.height > focused.rect.height,
"Measure the surrounding frame, including padding",
);
const frameBottom = frame.rect.y + frame.rect.height;
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
assert(
frameBottom <= pluginKeyboardTop + 1,
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
);
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
} finally {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
}
@@ -0,0 +1,45 @@
# Native retained status-ring regression
This runs the production native ring and clock inside real retained panels with
Reanimated on Android. A probe consumes the same public rotation hook and counts
UI-runtime value changes. No Reanimated mocks or provider processes are used.
Use an Android development client compatible with the checkout's native dependencies.
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
```tsx
import "./src/polyfills/install-early";
import "./src/styles/unistyles";
import { registerRootComponent } from "expo";
import NativeRingRegression from "./e2e/mobile/status-ring/native";
registerRootComponent(NativeRingRegression);
```
Start Metro in `packages/app`:
```sh
APP_VARIANT=development npx expo start --localhost --port 8096
```
With one Android emulator running, open that bundle:
```sh
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
```
From the repository root:
```sh
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
```
The test checks visible rotation, hidden-panel inactivity while another ring is
visible, inactivity with both panels hidden, and synchronized resumption. Each
sample reads counters on the UI runtime; elapsed time includes device-command
latency. The baseline fails at the first hidden-panel assertion.
Restore the original `packages/app/index.ts`, close the agent-device session,
and stop Metro afterward. The fixture substitutes running rings for a live agent
and toggles retained panels directly; it does not exercise workspace navigation
or measure whole-app CPU usage. It is excluded from routine unit tests because
it requires a native runtime.
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl] = process.argv;
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
return value === "undefined" ? undefined : JSON.parse(value);
}
let target;
const deadline = Date.now() + 30_000;
do {
target = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.find((line) => line.includes("sh.paseo.debug"));
if (!target) await delay(250);
} while (!target && Date.now() < deadline);
assert(target, "Open the Android dev client on this Metro bundle first");
ad("cdp", "target", "select", target.split(/\s+/)[0]);
let ready = false;
do {
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
if (!ready) await delay(250);
} while (!ready && Date.now() < deadline);
assert(ready, "The two native probes must mount before sampling");
async function sample() {
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
await delay(100);
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
}
async function observe(label) {
const before = await sample();
await delay(600);
const after = await sample();
const deltas = Object.fromEntries(
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
);
console.log(JSON.stringify({ label, deltas, after }));
return { deltas, after };
}
const visible = await observe("both visible");
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
ad("press", 'label="HIDE FIRST"');
await delay(200);
const hidden = await observe("first hidden, second visible");
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
ad("press", 'label="HIDE SECOND"');
await delay(200);
const allHidden = await observe("both hidden");
assert.equal(allHidden.deltas.first, 0);
assert.equal(allHidden.deltas.second, 0);
ad("press", 'label="SHOW FIRST"');
ad("press", 'label="SHOW SECOND"');
await delay(200);
const restored = await observe("both shown again");
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
assert.equal(
restored.after.first.rotation,
restored.after.second.rotation,
"Restored rings must share the current phase",
);
console.log("PASS native retained-ring lifecycle");
@@ -0,0 +1,69 @@
import { useCallback, useLayoutEffect, useState } from "react";
import { Button, Text, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
import { RetainedPanel } from "@/components/retained-panel";
import { useStatusRingRotation } from "@/components/status-ring/clock";
import { StatusRing } from "@/components/status-ring";
interface Sample {
updates: number;
rotation: number;
}
declare global {
var __ringSamples: Record<string, Sample>;
var __ringReaders: Record<string, () => void>;
}
function recordSample(name: string, value: { updates: number; rotation: number }) {
globalThis.__ringSamples[name] = value;
}
function Probe({ name }: { name: string }) {
const rotation = useStatusRingRotation();
const updates = useSharedValue(0);
useAnimatedReaction(
() => rotation.value,
() => {
updates.value += 1;
},
);
// Read the real UI runtime, without scheduling a continuous JS observer.
useLayoutEffect(() => {
globalThis.__ringSamples ??= {};
globalThis.__ringReaders ??= {};
globalThis.__ringReaders[name] = () =>
scheduleOnUI(() => {
"worklet";
const sample = { updates: updates.value, rotation: rotation.value };
scheduleOnRN(recordSample, name, sample);
});
return () => {
delete globalThis.__ringReaders[name];
};
}, [name, rotation, updates]);
return <StatusRing />;
}
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
export default function NativeRingRegression() {
const [first, setFirst] = useState(true);
const [second, setSecond] = useState(true);
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
return (
<View style={containerStyle}>
<Text>Native retained status rings</Text>
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
<RetainedPanel active={first}>
<Probe name="first" />
</RetainedPanel>
<RetainedPanel active={second}>
<Probe name="second" />
</RetainedPanel>
</View>
);
}
@@ -501,3 +501,59 @@ export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
}
/** Exercise the quick-return race before a hidden pane gets a rendering frame. */
export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
return new Promise<{ returnedBeforePaint: boolean; focused: boolean }>((resolve) => {
let switchedAway = false;
let painted = false;
let frame = 0;
const shortcut = (code: "BracketLeft" | "BracketRight") => {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: code === "BracketLeft" ? "{" : "}",
code,
altKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);
};
// Watch selection commits without reading layout: a layout read could make
// Chromium blur the hidden editor and erase the condition under test.
const observer = new MutationObserver(() => {
if (!switchedAway && tab.getAttribute("aria-selected") === "false") {
switchedAway = true;
frame = requestAnimationFrame(() => {
painted = true;
});
// Deliver the return shortcut before rendering, without a Playwright
// round trip allowing the browser to paint the inactive tab first.
shortcut("BracketRight");
} else if (switchedAway && tab.getAttribute("aria-selected") === "true") {
observer.disconnect();
cancelAnimationFrame(frame);
return resolve({
returnedBeforePaint: !painted,
focused: document.activeElement === editor,
});
}
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
shortcut("BracketLeft");
});
});
expect(
result.returnedBeforePaint,
"tab round trip must finish before rendering the hidden pane",
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
@@ -24,6 +24,7 @@ interface E2EDaemonClientConfig {
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
capabilities?: Record<string, unknown>;
}
function resolveDaemonWsUrl(port?: number): string {
@@ -34,6 +35,7 @@ export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
port?: number;
capabilities?: Record<string, unknown>;
}
/**
@@ -51,6 +53,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
capabilities: options.capabilities,
});
await client.connect();
return client;
@@ -35,6 +35,8 @@ export type { WorkspaceSetupDaemonClient };
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
clientIdPrefix: "workspace-setup",
// COMPAT(workspaceSetupObservation): legacy streaming coverage; remove after 2027-04-19.
capabilities: { workspace_setup_observation: false },
});
// Establish demand before a caller can launch setup. Client close releases it.
try {
@@ -326,3 +328,51 @@ export async function waitForWorkspaceSetupProgress(
});
});
}
/** Observe the browser's actual setup membership without requesting any output. */
export function observeSetupPanelTraffic(page: Page) {
const subscriptions: Array<{ workspaceId: string; subscriptionId: string }> = [];
const releases = new Set<string>();
const requests: string[] = [];
let legacySetupDemand = false;
const outputWorkspaces: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "subscription.release.request")
releases.add(frame.message.subscriptionId);
if (frame.message?.type === "workspace.setup.subscribe.request")
requests.push(frame.message.workspaceId);
if (
frame.message?.type === "session.events.set_subscription.request" &&
frame.message.events.includes("workspace_setup_progress")
)
legacySetupDemand = true;
});
socket.on("framereceived", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "workspace.setup.subscribe.response")
subscriptions.push(frame.message.payload);
if (frame.message?.type === "workspace_setup_progress")
outputWorkspaces.push(frame.message.payload.workspaceId);
});
});
return {
async expectNoDemand() {
expect(requests).toEqual([]);
expect(legacySetupDemand).toBe(false);
expect(subscriptions).toEqual([]);
expect(outputWorkspaces).toEqual([]);
},
async expectSubscribed(workspaceId: string, count: number) {
await expect.poll(() => subscriptions.length).toBe(count);
expect(subscriptions[count - 1].workspaceId).toBe(workspaceId);
expect(requests).toEqual(Array(count).fill(workspaceId));
},
async expectReleased(index: number) {
await expect.poll(() => releases.has(subscriptions[index].subscriptionId)).toBe(true);
},
};
}
@@ -12,6 +12,7 @@ useDefaultAndroidSdkVersions()
def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile
android {
ndkVersion rootProject.ext.ndkVersion
namespace "sh.paseo.diffprototype"
defaultConfig {
externalNativeBuild {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.3",
"version": "0.12.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -0,0 +1,22 @@
// After Expo prebuild, run from packages/app/android:
// ./gradlew --init-script ../scripts/verify-android-ndk.gradle verifyAndroidNdk
gradle.projectsEvaluated {
rootProject.tasks.register('verifyAndroidNdk') {
doLast {
def expected = rootProject.ext.ndkVersion
def nativeProjects = rootProject.subprojects.findAll { subproject ->
def android = subproject.extensions.findByName('android')
android != null && (android.externalNativeBuild.cmake.path != null ||
android.externalNativeBuild.ndkBuild.path != null)
}
assert !nativeProjects.empty : 'No native Android modules found'
println "Project NDK: ${expected}"
def mismatches = nativeProjects.findAll { subproject ->
def actual = subproject.android.ndkVersion
println "${subproject.path} NDK: ${actual}"
actual != expected
}
assert mismatches.empty : "Native Android modules must use project NDK ${expected}: ${mismatches*.path}"
}
}
}
@@ -1,4 +1,4 @@
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
@@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions(
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
@@ -41,7 +41,7 @@ import {
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { shouldShowWorkspaceSetup } from "@/stores/workspace-setup-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
@@ -188,6 +188,7 @@ export function useWorkspaceCommandCenterActions(): void {
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
setupStatus: workspace.setupStatus ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
@@ -195,12 +196,10 @@ export function useWorkspaceCommandCenterActions(): void {
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
const persistenceKey =
serverId && fields
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
: null;
const canShowSetup = useWorkspaceSetupStore((state) =>
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
const supportsSetupObservation = useHostFeature(serverId, "workspaceSetupObservation");
const canShowSetup = shouldShowWorkspaceSetup(
fields?.setupStatus ?? null,
supportsSetupObservation,
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
+1 -90
View File
@@ -11,10 +11,7 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
invalidConnection: "invalid connection",
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
timedOut: "timeout",
refused: "refused",
hostNotFound: "DNS failure",
hostUnreachable: "unreachable",
tlsError: "TLS failure",
unableToConnect: "connection failure",
};
function failure(message: string) {
return buildConnectionFailureCopy({
endpoint: "tcp://example.test:443?ssl=true",
error: new DaemonConnectionTestError(message, {
reason: "transport error",
lastError: message,
}),
labels,
});
}
describe("direct connection failure copy", () => {
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.tlsError,
raw: `transport error — ${message}`,
});
});
it.each([
["certificate not found", labels.tlsError],
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
["EHOSTUNREACH", labels.hostUnreachable],
["unexpected transport failure", labels.unableToConnect],
])("classifies %s", (message, expected) => {
expect(failure(message).detail).toBe(expected);
});
it("preserves authentication failures and their technical details", () => {
const message = "Invalid daemon password";
expect(
buildConnectionFailureCopy({
endpoint: "example.test:6767",
error: new DaemonConnectionTestError(message, {
reason: message,
lastError: message,
authFailureReason: "incorrect_password",
}),
labels,
}),
).toMatchObject({ detail: message, raw: message });
});
});
@@ -0,0 +1,103 @@
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
interface ConnectionFailureLabels {
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: ConnectionFailureLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
export function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: ConnectionFailureLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
rawLower.includes("trust anchor") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
@@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
serverId: string;
@@ -150,7 +151,7 @@ export function ContextWindowMeter({
showPercentage = false,
glyphSize,
}: ContextWindowMeterProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
@@ -192,7 +193,9 @@ export function ContextWindowMeter({
);
const percentageLabel =
showPercentage && context ? (
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
<Text style={styles.percentageLabel}>
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
</Text>
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { formatContextPercentage } from "./context-window-meter.utils";
describe("context window meter formatting", () => {
it("localizes percentages without hiding context overflow", () => {
expect(formatContextPercentage(125, "en")).toBe("125%");
expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%");
});
});
@@ -1,3 +1,10 @@
export function formatContextPercentage(value: number, locale?: string): string {
return new Intl.NumberFormat(locale, {
style: "percent",
maximumFractionDigits: 0,
}).format(value / 100);
}
export function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
@@ -1,17 +0,0 @@
export type FilePreviewRenderKind = "markdown" | "html";
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}
function isRenderedHtmlFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm");
}
export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null {
if (isRenderedMarkdownFile(filePath)) return "markdown";
if (isRenderedHtmlFile(filePath)) return "html";
return null;
}
@@ -1,10 +1,14 @@
import { useMemo, type ReactNode } from "react";
import { ScrollView, Text, View } from "react-native";
import { Platform, ScrollView, Text, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
const PageScrollView = Platform.OS === "android" ? KeyboardAwareScrollView : ScrollView;
interface PageLayoutProps {
title?: string;
@@ -42,7 +46,13 @@ export function PageLayout({
) : (
<MenuHeader borderless />
)}
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<PageScrollView
// Leave room for field borders and padding around the focused native input.
{...(Platform.OS === "android" ? { bottomOffset: SPACING[4] } : {})}
style={styles.scroll}
contentContainerStyle={scrollContentStyle}
testID={testID}
>
<View style={styles.content}>
{showTitle ? (
<View style={styles.titleRow} testID={titleTestID}>
@@ -54,7 +64,7 @@ export function PageLayout({
) : null}
{children}
</View>
</ScrollView>
</PageScrollView>
</View>
);
}
@@ -1,11 +1,13 @@
import { useLayoutEffect } from "react";
import { useLayoutEffect, useState } from "react";
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { getStatusRingRotation } from "@/components/status-ring/geometry";
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
const activeRingCount = makeMutable(0);
const clockRunning = makeMutable(false);
let nextListenerId = 1;
function advanceSharedRotation(): void {
"worklet";
@@ -18,13 +20,21 @@ function advanceSharedRotation(): void {
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(registered: SharedValue<boolean>): void {
function registerStatusRing(
rotation: SharedValue<number>,
registered: SharedValue<boolean>,
listenerId: number,
): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
rotation.value = getStatusRingRotation(Date.now());
sharedRotation.addListener(listenerId, (value) => {
rotation.value = value;
});
activeRingCount.value += 1;
if (!clockRunning.value) {
@@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue<boolean>): void {
}
}
function unregisterStatusRing(registered: SharedValue<boolean>): void {
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
sharedRotation.removeListener(listenerId);
activeRingCount.value -= 1;
}
export function useStatusRingRotation(): SharedValue<number> {
const active = useRetainedPanelActive();
// Hidden retained views must not subscribe their animated styles to the shared clock.
const rotation = useSharedValue(getStatusRingRotation(Date.now()));
const registered = useSharedValue(false);
const [listenerId] = useState(() => nextListenerId++);
useLayoutEffect(() => {
scheduleOnUI(registerStatusRing, registered);
if (!active) return;
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
return () => {
scheduleOnUI(unregisterStatusRing, registered);
scheduleOnUI(unregisterStatusRing, registered, listenerId);
};
}, [registered]);
}, [active, listenerId, registered, rotation]);
return sharedRotation;
return rotation;
}
@@ -868,7 +868,9 @@ function NativeTerminalEmulator({
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
@@ -915,7 +917,10 @@ function NativeTerminalEmulator({
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (selectionPress?.status === "selecting") {
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
+6 -1
View File
@@ -1295,7 +1295,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// Read pointer capability when restoring, without making a pointer change itself
// a focus trigger.
useLayoutEffect(() => {
if (isPaneFocused && focusIntentRef.current && hasFinePointer()) {
const finePointer = hasFinePointer();
if (!isPaneFocused && isWeb && !finePointer) {
// Hiding a retained tab only blurs its editor after the browser renders.
// A quick return before that frame would otherwise keep touch focus.
textInputRef.current?.blur();
} else if (isPaneFocused && focusIntentRef.current && finePointer) {
textInputRef.current?.focus();
}
}, [isPaneFocused]);
+19 -23
View File
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } fro
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
MENU_ITEM_HEIGHT,
MenuRoot,
MenuSeparator,
MenuSurface,
@@ -312,6 +313,22 @@ const styles = StyleSheet.create((theme) => {
backgroundColor: getStatusDotColor({ theme, bucket, showDoneAsInactive: true }) ?? undefined,
});
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
//
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
// on compact; a pointer does not, and the popover keeps its density.
const trackRow = {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
borderRadius: theme.borderRadius.md,
} as const;
return {
bar: {
position: "absolute",
@@ -332,29 +349,8 @@ const styles = StyleSheet.create((theme) => {
alignItems: "center",
gap: theme.spacing[1],
},
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
rowActive: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
row: trackRow,
rowActive: { ...trackRow, backgroundColor: theme.colors.surface2 },
// Segments sit twice as far apart as a mark sits from its own text, so a count reads as
// belonging to the mark on its left rather than to the words on its right.
segments: {
@@ -36,7 +36,6 @@ import {
useSessionStore,
type SessionState,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
import {
@@ -168,11 +167,6 @@ function resolveAgentAttentionNotification(
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
function notifyVoiceAbortFailure(
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
notifyError: (message: string) => void,
@@ -225,7 +219,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
@@ -365,13 +358,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
upsertWorkspaceSetupProgress({ serverId, payload });
},
[serverId, upsertWorkspaceSetupProgress],
);
useEffect(() => {
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
const onStream = (message: SessionOutboundMessage) => {
@@ -508,7 +494,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
"agent_permission_resolved",
"agent.provider_subagents.update",
"checkout_status_update",
"workspace_setup_progress",
"status.server_info",
],
{ notifications: true },
@@ -547,11 +532,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
});
const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload);
});
const unsubStatus = onFeed("status", (message) => {
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
@@ -710,7 +690,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubCheckoutStatusUpdate();
unsubWorkspaceSetupProgress();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -732,7 +711,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgents,
setPendingPermissions,
notifyAgentAttention,
applyWorkspaceSetupProgress,
updateSessionServerInfo,
toast,
voiceRuntime,
@@ -4,7 +4,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isRenderedMarkdownFile } from "@/file-panels";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
+34 -79
View File
@@ -1,20 +1,13 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ScrollView as RNScrollView, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
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";
@@ -28,8 +21,6 @@ import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
@@ -39,7 +30,6 @@ 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";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -47,6 +37,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
});
interface FilePreviewBodyProps {
panel: FilePanel | null;
preview: ExplorerFile | null;
mode?: "preview" | "source";
isLoading: boolean;
@@ -131,6 +122,7 @@ function TooLargeSource({ size }: { size?: number }) {
}
function FilePreviewBody({
panel,
preview,
mode,
isLoading,
@@ -144,14 +136,6 @@ function FilePreviewBody({
}: FilePreviewBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to
// the highlighted source view even for renderable files.
const renderKind =
preview?.kind === "text" && !location.lineStart && mode !== "source"
? filePreviewRenderKind(filePath)
: null;
const previewScrollRef = useRef<RNScrollView>(null);
if (isLoading && !preview) {
return (
@@ -170,36 +154,21 @@ function FilePreviewBody({
);
}
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") {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={preview.content ?? ""} testID="file-html-preview" />
</View>
);
}
if (renderKind === "markdown") {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
showsVerticalScrollIndicator
>
<FileMarkdownPreview
source={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</RNScrollView>
</View>
);
}
return (
<ReadonlySource
preview={preview}
@@ -210,19 +179,6 @@ function FilePreviewBody({
);
}
if (preview.kind === "image") {
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" />;
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
@@ -243,6 +199,7 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const { settings } = useAppSettings();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
@@ -292,7 +249,12 @@ export function FilePane({
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const isRenderable = isRenderablePreview(preview, location.path);
const panel = resolveFilePreviewPanel({
preview,
path: location.path,
associations: settings.filePanelAssociations,
});
const isRenderable = preview?.kind === "text" && panel !== null;
const editable = isEditableTextFile({
preview,
supportsEditing,
@@ -308,6 +270,7 @@ export function FilePane({
return (
<FilePanePresentation
panel={panel}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
@@ -333,10 +296,6 @@ export function FilePane({
);
}
function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean {
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
@@ -350,6 +309,7 @@ function isEditableTextFile(input: {
}
function FilePanePresentation({
panel,
serverId,
workspaceRoot,
client,
@@ -372,6 +332,7 @@ function FilePanePresentation({
navigationRevision,
imagePreviewUri,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
@@ -407,6 +368,7 @@ function FilePanePresentation({
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
panel={panel}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
@@ -459,6 +421,7 @@ function FilePanePresentation({
/>
) : null}
<FilePreviewBody
panel={panel}
preview={preview}
mode={previewMode}
isLoading={isLoading}
@@ -475,6 +438,7 @@ function FilePanePresentation({
}
function EditableFilePane({
panel,
serverId,
workspaceRoot,
client,
@@ -492,6 +456,7 @@ function EditableFilePane({
location,
navigationRevision,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
@@ -639,6 +604,7 @@ function EditableFilePane({
/>
) : (
<FilePreviewBody
panel={panel}
preview={renderedPreview}
mode={mode}
isLoading={isLoading}
@@ -708,15 +674,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
previewScrollContainer: {
flex: 1,
minHeight: 0,
},
previewContent: {
flex: 1,
minHeight: 0,
},
previewCodeScrollContent: {
padding: theme.spacing[4],
},
}));
+92
View File
@@ -0,0 +1,92 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
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 { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { Theme } from "@/styles/theme";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import type { builtinFilePanels } from "./catalog";
interface FilePanelPreviewProps {
content: string;
filePath: string;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
imagePreviewUri: string | null;
}
function MarkdownPanel(props: FilePanelPreviewProps) {
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}
/>
</ScrollView>
</View>
);
}
function HtmlPanel({ content }: FilePanelPreviewProps) {
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={content} testID="file-html-preview" />
</View>
);
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
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" />;
}
const components = {
"builtin:markdown": MarkdownPanel,
"builtin:html": HtmlPanel,
"builtin:image": ImagePanel,
};
export function FilePanelPreview({
panel,
...props
}: FilePanelPreviewProps & {
panel: (typeof builtinFilePanels)[number];
}) {
const Component = components[panel.id];
return <Component {...props} />;
}
const styles = StyleSheet.create((theme) => ({
previewScrollContainer: { flex: 1, minHeight: 0 },
previewContent: { 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,
fontSize: theme.fontSize.base,
},
}));
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { filePreviewRenderKind, isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
import { resolveFilePanel } 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;
}
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
@@ -38,3 +48,39 @@ describe("filePreviewRenderKind", () => {
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
});
});
describe("built-in file panels", () => {
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,
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,
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");
});
});
+28
View File
@@ -0,0 +1,28 @@
import { filePanelExtension, 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,
{
id: "builtin:html",
files: { extensions: ["html", "htm"] },
document: "text",
maxBytes: Infinity,
},
{
id: "builtin:image",
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
document: "bytes",
maxBytes: Infinity,
},
] as const satisfies readonly FilePanelCandidate[];
export function isRenderedMarkdownFile(path: string): boolean {
return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path));
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { withPreviewCsp } from "@/file-pane/html-preview-csp";
import { withPreviewCsp } from "./html-preview-csp";
describe("withPreviewCsp", () => {
it("places the policy before the complete source document", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { htmlPreviewNavigationKind } from "@/file-pane/html-preview-navigation";
import { htmlPreviewNavigationKind } from "./html-preview-navigation";
describe("htmlPreviewNavigationKind", () => {
it("allows the WebView's initial document URLs", () => {
+23
View File
@@ -0,0 +1,23 @@
import type { ExplorerFile } from "@/stores/session-store";
import { builtinFilePanels } from "./catalog";
import { resolveFilePanel } from "./resolve";
export { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
export { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
export { FilePanelPreview } from "./builtin";
export type FilePanel = (typeof builtinFilePanels)[number];
export function resolveFilePreviewPanel(input: {
preview: ExplorerFile | null;
path: string;
associations: Readonly<Record<string, string>>;
}) {
const { preview, path, associations } = input;
if (!preview) return null;
return resolveFilePanel({
file: { path, kind: preview.kind, size: preview.size, hasBytes: preview.kind === "image" },
candidates: builtinFilePanels,
associations,
}).panel;
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
const markdown: FilePanelCandidate = {
id: "builtin:markdown",
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
};
const alternative: FilePanelCandidate = { ...markdown, id: "plugin:notes/markdown" };
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
function resolve(
overrides: Partial<Parameters<typeof resolveFilePanel<FilePanelCandidate>>[0]> = {},
) {
return resolveFilePanel({
file,
candidates: [markdown, alternative],
associations: {},
...overrides,
});
}
describe("resolveFilePanel", () => {
it("uses the associated candidate before catalog order", () => {
expect(resolve({ associations: { "ext:md": alternative.id } })).toEqual({
panel: alternative,
candidates: [markdown, alternative],
selectorKey: "ext:md",
});
});
it("ignores an absent association without deleting it", () => {
const associations = { "ext:md": "plugin:absent/markdown" };
expect(resolve({ associations }).panel).toBe(markdown);
expect(associations).toEqual({ "ext:md": "plugin:absent/markdown" });
});
it("uses a single candidate and preserves catalog order for multiple candidates", () => {
expect(resolve({ candidates: [alternative] }).panel).toBe(alternative);
expect(resolve().panel).toBe(markdown);
expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative);
});
it("returns the floor when no selector matches", () => {
expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({
panel: null,
candidates: [],
selectorKey: "ext:ts",
});
});
it("filters text panels for binary files even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
).toBeNull();
});
it("requires bytes for bytes panels and accepts binary files with bytes", () => {
const bytes: FilePanelCandidate = { ...alternative, document: "bytes" };
expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull();
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes);
});
it("includes the maxBytes boundary and skips oversized associated panels", () => {
const limited = { ...alternative, maxBytes: 100 };
expect(resolve({ candidates: [limited] }).panel).toBe(limited);
expect(
resolve({
file: { ...file, size: 101 },
candidates: [limited, markdown],
associations: { "ext:md": limited.id },
}).panel,
).toBe(markdown);
});
it("uses exact basename keys when an eligible candidate matches by filename", () => {
const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } };
expect(
resolve({ candidates: [markdown, named], associations: { "name:README.MD": named.id } }),
).toEqual({
panel: named,
candidates: [markdown, named],
selectorKey: "name:README.MD",
});
expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull();
});
it("uses extension keys when filename candidates were filtered out", () => {
const named = {
...alternative,
maxBytes: 1,
files: { extensions: [], filenames: ["README.MD"] },
};
expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md");
});
it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])(
"matches an extensionless exact filename: %s",
(path) => {
const named = { ...alternative, files: { extensions: [], filenames: ["Dockerfile"] } };
expect(resolve({ file: { ...file, path }, candidates: [named] })).toEqual({
panel: named,
candidates: [named],
selectorKey: "name:Dockerfile",
});
},
);
it("normalizes extension selectors and keeps extensionless files distinct", () => {
const upper = { ...alternative, files: { extensions: ["MD"] } };
expect(resolve({ candidates: [upper] }).panel).toBe(upper);
expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:");
});
});
+63
View File
@@ -0,0 +1,63 @@
export interface FilePanelFile {
path: string;
kind: "text" | "image" | "binary";
size: number;
hasBytes: boolean;
}
export interface FilePanelSelector {
extensions: readonly string[];
filenames?: readonly string[];
}
export interface FilePanelCandidate {
id: string;
files: FilePanelSelector;
document: "text" | "bytes";
maxBytes: number;
}
export interface FilePanelResolution<Candidate> {
panel: Candidate | null;
candidates: readonly Candidate[];
selectorKey: string;
}
export function filePanelExtension(path: string): string {
const name = filePanelBasename(path);
const dot = name.lastIndexOf(".");
return dot < 0 ? "" : name.slice(dot + 1).toLowerCase();
}
function filePanelBasename(path: string): string {
return path.trim().split(/[\\/]/).pop() ?? "";
}
function matchesSelector(file: FilePanelFile, selector: FilePanelSelector): boolean {
if (selector.filenames?.includes(filePanelBasename(file.path))) return true;
const extension = filePanelExtension(file.path);
return selector.extensions.some((entry) => entry.toLowerCase() === extension);
}
export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
file: FilePanelFile;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
}): FilePanelResolution<Candidate> {
const { file, associations } = input;
const candidates = input.candidates.filter((candidate) => {
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
return (
documentAvailable && file.size <= candidate.maxBytes && matchesSelector(file, candidate.files)
);
});
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) =>
candidate.files.filenames?.includes(filename),
);
const selectorKey = matchedFilename ? `name:${filename}` : `ext:${filePanelExtension(file.path)}`;
const association = associations[selectorKey];
const panel =
candidates.find((candidate) => candidate.id === association) ?? candidates[0] ?? null;
return { panel, candidates, selectorKey };
}
@@ -15,7 +15,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -129,14 +129,10 @@ export function GitActionsSplitButton({
[theme.colors.surface2],
);
const menuOnlyActions = useMemo(
() => [
...(gitActions.primary ? [gitActions.primary] : []),
...gitActions.secondary,
...gitActions.menu,
],
[gitActions.menu, gitActions.primary, gitActions.secondary],
);
const menuOnlyActions = [
...(gitActions.primary ? [gitActions.primary] : []),
...actionsWithoutPrimary(gitActions),
];
if (menuOnly) {
if (menuOnlyActions.length === 0) {
+54 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
@@ -190,6 +190,59 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("drops a promoted remote action from the remaining menu actions", () => {
const pull = buildGitActions(createInput({ hasRemote: true, behindOfOrigin: 2 }));
expect(pull.primary?.id).toBe("pull");
expect(actionsWithoutPrimary(pull).map((action) => action.id)).toEqual([
"push",
"pull-and-push",
"archive-workspace",
]);
const push = buildGitActions(createInput({ hasRemote: true, aheadOfOrigin: 1 }));
expect(push.primary?.id).toBe("push");
expect(actionsWithoutPrimary(push).map((action) => action.id)).toEqual([
"pull",
"pull-and-push",
"archive-workspace",
]);
});
it("drops a promoted merge action from the remaining menu actions", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "merge-pr-merge", label: "Merge PR (merge)" });
expect(actionsWithoutPrimary(actions).map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"archive-workspace",
]);
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
+12
View File
@@ -312,6 +312,18 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
};
}
/**
* The actions a flattened list shows after the primary. `secondary` keeps the actions the policy
* can promote to `primary`, and the translator rebuilds those objects, so a flattening consumer
* has to drop the promoted one by id or it renders it twice.
*/
export function actionsWithoutPrimary(gitActions: GitActions): GitAction[] {
const primaryActionId = gitActions.primary?.id;
return [...gitActions.secondary, ...gitActions.menu].filter(
(action) => action.id !== primaryActionId,
);
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive) {
return "archive-workspace";
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type {
CheckoutPipelineJob,
CheckoutPrStatusResponse,
@@ -780,6 +780,11 @@ describe("formatAge", () => {
});
});
// Fixed translators do not select a language, so request their resources first.
beforeAll(async () => {
await i18n.loadLanguages("fr");
});
const english = i18n.getFixedT("en");
const french = i18n.getFixedT("fr");
@@ -44,6 +44,33 @@ function makeDeps(
}
describe("loadAppSettingsFromStorage", () => {
it("persists file panel associations, including panels absent from this host", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
const filePanelAssociations = {
"ext:md": "builtin:markdown",
"name:Dockerfile": "plugin:absent/container",
};
await saveAppSettings({ queryClient, deps, updates: { filePanelAssociations } });
expect((await loadAppSettingsFromStorage(deps)).filePanelAssociations).toEqual(
filePanelAssociations,
);
});
it.each([undefined, null, [], "invalid", { "ext:md": 42 }])(
"defaults malformed file panel associations without discarding other settings: %j",
async (filePanelAssociations) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ filePanelAssociations, vimKeybindings: true }),
}),
});
const settings = await loadAppSettingsFromStorage(deps);
expect(settings.filePanelAssociations).toEqual({});
expect(settings.vimKeybindings).toBe(true);
},
);
it("preserves a persisted steer send behavior", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -104,6 +104,7 @@ export interface AppSettings {
toolCallDetailLevel: ToolCallDetailLevel;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
filePanelAssociations: Record<string, string>;
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
openInSidePane: OpenInSidePanePreferences;
pullRequestOpenLocation: PullRequestOpenLocation;
@@ -160,6 +161,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
toolCallDetailLevel: "detailed",
chatOutlineEnabled: true,
vimKeybindings: false,
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
};
@@ -261,6 +263,7 @@ const StoredAppSettingsSchema = z
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
openInSidePane: z
.object({
explorerFiles: z.boolean().catch(false),
@@ -0,0 +1,92 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { build } from "esbuild";
import { beforeAll, describe, expect, it } from "vitest";
let bundle: string;
// Exercise the bundled entry in fresh processes so the test runner's module cache
// cannot hide eager evaluation. The probe only records resource module execution.
beforeAll(async () => {
const result = await build({
entryPoints: [path.join(import.meta.dirname, "i18next.ts")],
bundle: true,
platform: "node",
format: "cjs",
external: ["i18next", "react-i18next"],
write: false,
plugins: [
{
name: "observe-translation-evaluation",
setup(builder) {
builder.onLoad(
{ filter: /[/\\]i18n[/\\]resources[/\\][a-z]{2}(?:-[A-Z]{2})?\.ts$/ },
async ({ path: file }) => ({
contents: `globalThis.evaluatedLocales.push(${JSON.stringify(path.basename(file, ".ts"))});\n${await readFile(file, "utf8")}`,
loader: "ts",
}),
);
},
},
],
});
bundle = result.outputFiles[0].text;
});
function runStartupProbe(actions = "") {
return JSON.parse(
execFileSync(process.execPath, ["-"], {
cwd: path.resolve(import.meta.dirname, "../.."),
encoding: "utf8",
input: `globalThis.evaluatedLocales = [];
(function () { ${bundle} })();
const { i18n } = module.exports;
${actions}
console.log(JSON.stringify({
evaluated: globalThis.evaluatedLocales,
registered: Object.keys(i18n.store.data),
language: i18n.language,
cancel: i18n.t("common.actions.cancel"),
fallback: i18n.t("startupProbeOnlyInEnglish"),
}));`,
}),
);
}
describe("bundled translation startup", () => {
it("evaluates and registers only English before a language is selected", () => {
expect(runStartupProbe()).toMatchObject({
evaluated: ["en"],
registered: ["en"],
language: "en",
cancel: "Cancel",
});
});
it.each([
["ar", "يلغي"],
["es", "Cancelar"],
["fr", "Annuler"],
["ja", "キャンセル"],
["ko", "취소"],
["pt-BR", "Cancelar"],
["ru", "Отмена"],
["zh-CN", "取消"],
])("loads %s synchronously on selection and reuses it", (locale, cancel) => {
const result = runStartupProbe(`
i18n.addResource("en", "translation", "startupProbeOnlyInEnglish", "English fallback");
i18n.changeLanguage(${JSON.stringify(locale)});
if (i18n.language !== ${JSON.stringify(locale)}) throw new Error("Language selection became asynchronous");
i18n.changeLanguage("en");
i18n.changeLanguage(${JSON.stringify(locale)});
`);
expect(result).toEqual({
evaluated: ["en", locale],
registered: ["en", locale],
language: locale,
cancel,
fallback: "English fallback",
});
});
});
+60 -31
View File
@@ -1,41 +1,70 @@
import { createInstance } from "i18next";
import { createInstance, type BackendModule } from "i18next";
import { initReactI18next } from "react-i18next";
import { observeI18nInit } from "./init";
import { ar } from "./resources/ar";
import { en } from "./resources/en";
import { es } from "./resources/es";
import { fr } from "./resources/fr";
import { ja } from "./resources/ja";
import { ko } from "./resources/ko";
import { ptBR } from "./resources/pt-BR";
import { ru } from "./resources/ru";
import { zhCN } from "./resources/zh-CN";
// Literal requires keep every locale bundled for offline use, while evaluating
// its resource module only when selected. Synchronous reads preserve the language
// provider's contract: translations are ready before it renders children.
const bundledTranslations: BackendModule = {
type: "backend",
init() {},
read(language, _namespace, callback) {
switch (language) {
case "en":
callback(null, en);
return;
case "ar":
callback(null, require("./resources/ar").ar);
return;
case "es":
callback(null, require("./resources/es").es);
return;
case "fr":
callback(null, require("./resources/fr").fr);
return;
case "ja":
callback(null, require("./resources/ja").ja);
return;
case "ko":
callback(null, require("./resources/ko").ko);
return;
case "pt-BR":
callback(null, require("./resources/pt-BR").ptBR);
return;
case "ru":
callback(null, require("./resources/ru").ru);
return;
case "zh-CN":
callback(null, require("./resources/zh-CN").zhCN);
return;
default:
callback(null, undefined);
}
},
};
const i18n = createInstance();
observeI18nInit(
i18n.use(initReactI18next).init({
compatibilityJSON: "v4",
fallbackLng: "en",
lng: "en",
resources: {
ar: { translation: ar },
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
ja: { translation: ja },
ko: { translation: ko },
"pt-BR": { translation: ptBR },
ru: { translation: ru },
"zh-CN": { translation: zhCN },
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
}),
i18n
.use(bundledTranslations)
.use(initReactI18next)
.init({
compatibilityJSON: "v4",
fallbackLng: "en",
lng: "en",
partialBundledLanguages: true,
resources: {
en: { translation: en },
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
}),
);
export { i18n };
+42 -9
View File
@@ -20,6 +20,30 @@ function flattenKeys(value: unknown, prefix = ""): string[] {
return entries.flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key));
}
const pluralSuffixPattern = /_(zero|one|two|few|many|other)$/;
const arabicPluralSuffixes = ["zero", "one", "two", "few", "many", "other"] as const;
const arabicProviderUsagePluralKeys = [
"providerUsage.duration.days",
"providerUsage.duration.hours",
"providerUsage.duration.minutes",
"providerUsage.timing.daysAgo",
"providerUsage.timing.hoursAgo",
"providerUsage.timing.minutesAgo",
] as const;
function canonicalKeys(value: unknown): string[] {
const keys = flattenKeys(value);
const keySet = new Set(keys);
return [
...new Set(
keys.map((key) => {
const baseKey = key.replace(pluralSuffixPattern, "");
return baseKey !== key && keySet.has(baseKey) ? baseKey : key;
}),
),
].sort();
}
function flattenStrings(value: unknown, prefix = ""): Record<string, string> {
if (typeof value === "string") {
return { [prefix]: value };
@@ -144,15 +168,24 @@ function findUntranslatedConnectionErrors(): string[] {
describe("translation resources", () => {
it("keeps all supported language keys in sync with English", () => {
const englishKeys = flattenKeys(en).sort();
expect(flattenKeys(ar).sort()).toEqual(englishKeys);
expect(flattenKeys(es).sort()).toEqual(englishKeys);
expect(flattenKeys(fr).sort()).toEqual(englishKeys);
expect(flattenKeys(ja).sort()).toEqual(englishKeys);
expect(flattenKeys(ko).sort()).toEqual(englishKeys);
expect(flattenKeys(ptBR).sort()).toEqual(englishKeys);
expect(flattenKeys(ru).sort()).toEqual(englishKeys);
expect(flattenKeys(zhCN).sort()).toEqual(englishKeys);
const englishKeys = canonicalKeys(en);
expect(canonicalKeys(ar)).toEqual(englishKeys);
expect(canonicalKeys(es)).toEqual(englishKeys);
expect(canonicalKeys(fr)).toEqual(englishKeys);
expect(canonicalKeys(ja)).toEqual(englishKeys);
expect(canonicalKeys(ko)).toEqual(englishKeys);
expect(canonicalKeys(ptBR)).toEqual(englishKeys);
expect(canonicalKeys(ru)).toEqual(englishKeys);
expect(canonicalKeys(zhCN)).toEqual(englishKeys);
});
it("includes every Arabic form for provider-usage durations", () => {
const arabicStrings = flattenStrings(ar);
for (const key of arabicProviderUsagePluralKeys) {
for (const suffix of arabicPluralSuffixes) {
expect(arabicStrings[`${key}_${suffix}`]).toBeTypeOf("string");
}
}
});
it("keeps non-English supported languages translated beyond fallback labels", () => {
+102
View File
@@ -538,6 +538,7 @@ export const ar: TranslationResources = {
},
},
setup: {
updateHost: "حدّث المضيف لعرض تفاصيل إعداد مساحة العمل",
descriptor: {
label: "يثبت",
completed: "اكتمل الإعداد",
@@ -2045,6 +2046,106 @@ export const ar: TranslationResources = {
saveAccessibility: "حفظ تعليق المراجعة",
},
},
providerUsage: {
options: "الإعدادات",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
agentError: "تعذّر تحميل الاستخدام: {{reason}}",
pin: "تثبيت",
unpin: "إلغاء التثبيت",
displayAs: "النسب المئوية",
displayUsed: "المستخدم",
displayRemaining: "المتبقي",
showInSidebar: "ملخص في الشريط الجانبي",
showInSidebarHint: "تظهر الفترات المثبتة في أسفل الشريط الجانبي",
runToRefresh: "شغّل {{command}} للتحديث.",
signInAgain: "سجّل الدخول مجددًا.",
loginRejected: "تم رفض تسجيل الدخول (HTTP {{status}}). {{remedy}}",
loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}",
title: "استخدام الخطة",
refresh: "تحديث",
refreshAll: "تحديث الكل",
refreshing: "جارٍ التحديث...",
loading: "جارٍ تحميل بيانات الاستخدام...",
empty: "لا توجد بيانات استخدام",
errorTitle: "تعذّر تحميل بيانات الاستخدام",
hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد",
hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد",
clientUnavailable: "اتصال المضيف غير جاهز",
retry: "حاول مرة أخرى",
tooltipLoading: "جارٍ تحميل استخدام الخطة...",
status: {
error: "خطأ",
unavailable: "غير متاح",
},
values: {
used: "تم استخدام {{percentage}}",
remaining: "متبقٍ {{amount}}",
},
labels: {
session: "الجلسة",
weekly: "أسبوعي",
monthly: "شهري",
codeReview: "مراجعة التعليمات البرمجية",
credits: "الأرصدة",
monthlyCredits: "أرصدة شهرية",
},
duration: {
days: "{{value}} يوم",
days_zero: "{{value}} يوم",
days_one: "يوم واحد",
days_two: "يومين",
days_few: "{{value}} أيام",
days_many: "{{value}} يومًا",
days_other: "{{value}} يوم",
hours: "{{value}} ساعة",
hours_zero: "{{value}} ساعة",
hours_one: "ساعة واحدة",
hours_two: "ساعتين",
hours_few: "{{value}} ساعات",
hours_many: "{{value}} ساعة",
hours_other: "{{value}} ساعة",
minutes: "{{value}} دقيقة",
minutes_zero: "{{value}} دقيقة",
minutes_one: "دقيقة واحدة",
minutes_two: "دقيقتين",
minutes_few: "{{value}} دقائق",
minutes_many: "{{value}} دقيقة",
minutes_other: "{{value}} دقيقة",
},
timing: {
lastUpdated: "آخر تحديث {{relativeTime}}",
noSuccessfulUpdate: "لا يوجد تحديث ناجح",
resetTimePassed: "مضى وقت إعادة التعيين",
resettingNow: "تتم إعادة التعيين الآن",
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
runsOutNow: "ينفد الآن",
runsOutIn: "ينفد خلال {{duration}}",
justNow: "الآن",
daysAgo: "قبل {{value}} يومًا",
daysAgo_zero: "قبل {{value}} يوم",
daysAgo_one: "قبل يوم واحد",
daysAgo_two: "قبل يومين",
daysAgo_few: "قبل {{value}} أيام",
daysAgo_many: "قبل {{value}} يومًا",
daysAgo_other: "قبل {{value}} يوم",
hoursAgo: "قبل {{value}} ساعة",
hoursAgo_zero: "قبل {{value}} ساعة",
hoursAgo_one: "قبل ساعة واحدة",
hoursAgo_two: "قبل ساعتين",
hoursAgo_few: "قبل {{value}} ساعات",
hoursAgo_many: "قبل {{value}} ساعة",
hoursAgo_other: "قبل {{value}} ساعة",
minutesAgo: "قبل {{value}} دقيقة",
minutesAgo_zero: "قبل {{value}} دقيقة",
minutesAgo_one: "قبل دقيقة واحدة",
minutesAgo_two: "قبل دقيقتين",
minutesAgo_few: "قبل {{value}} دقائق",
minutesAgo_many: "قبل {{value}} دقيقة",
minutesAgo_other: "قبل {{value}} دقيقة",
updated: "تم التحديث {{relativeTime}}",
},
},
settings: {
title: "إعدادات",
loading: "جارٍ تحميل الإعدادات...",
@@ -2333,6 +2434,7 @@ export const ar: TranslationResources = {
searchPlaceholder: "البحث في الاختصارات",
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
capturePrompt: "اضغط على الاختصار...",
captureIndexPrompt: "اختم برقم من 1 إلى 9.",
unassigned: "غير معين",
actions: {
menu: "إجراءات {{name}}",
+81 -1
View File
@@ -535,6 +535,7 @@ export const en = {
},
},
setup: {
updateHost: "Update the host to view workspace setup details",
descriptor: {
label: "Setup",
completed: "Setup completed",
@@ -2070,6 +2071,70 @@ export const en = {
saveAccessibility: "Save review comment",
},
},
providerUsage: {
options: "Settings",
refreshFailed: "Unable to refresh usage",
noHosts: "No connected hosts",
agentError: "Unable to load usage: {{reason}}",
pin: "Pin",
unpin: "Unpin",
displayAs: "Percentages",
displayUsed: "Used",
displayRemaining: "Remaining",
showInSidebar: "Summary in sidebar",
showInSidebarHint: "Pinned windows show in the sidebar footer",
runToRefresh: "Run {{command}} to refresh it.",
signInAgain: "Sign in again.",
loginRejected: "Login rejected (HTTP {{status}}). {{remedy}}",
loginExpired: "Login expired {{relativeTime}}. {{remedy}}",
title: "Plan usage",
refresh: "Refresh",
refreshAll: "Refresh all",
refreshing: "Refreshing...",
loading: "Loading usage...",
empty: "No usage data",
errorTitle: "Unable to load usage",
hostUnavailable: "Connect to {{host}} to see usage",
hostUpgradeRequired: "Update {{host}} to see usage",
clientUnavailable: "Host connection is not ready",
retry: "Try again",
tooltipLoading: "Loading plan usage...",
status: {
error: "Error",
unavailable: "Unavailable",
},
values: {
used: "{{percentage}} used",
remaining: "{{amount}} left",
},
labels: {
session: "Session",
weekly: "Weekly",
monthly: "Monthly",
codeReview: "Code review",
credits: "Credits",
monthlyCredits: "Monthly credits",
},
duration: {
days: "{{value}}d",
hours: "{{value}}h",
minutes: "{{value}}m",
},
timing: {
lastUpdated: "Last updated {{relativeTime}}",
noSuccessfulUpdate: "No successful update",
resetTimePassed: "Reset time passed",
resettingNow: "resetting now",
resetsIn: "resets {{duration}}",
runsOutNow: "runs out now",
runsOutIn: "runs out {{duration}}",
justNow: "just now",
daysAgo: "{{value}}d ago",
hoursAgo: "{{value}}h ago",
minutesAgo: "{{value}}m ago",
updated: "Updated {{relativeTime}}",
},
},
settings: {
title: "Settings",
loading: "Loading settings...",
@@ -2456,6 +2521,7 @@ export const en = {
searchPlaceholder: "Search shortcuts",
unavailableOnMobile: "Keyboard shortcuts are only available on desktop",
capturePrompt: "Press shortcut...",
captureIndexPrompt: "End with a number from 1 to 9.",
unassigned: "Not set",
actions: {
menu: "Actions for {{name}}",
@@ -2971,4 +3037,18 @@ type WidenStringLeaves<T> = {
[K in keyof T]: T[K] extends string ? string : WidenStringLeaves<T[K]>;
};
export type TranslationResources = WidenStringLeaves<typeof en>;
type TranslationResourcesBase = WidenStringLeaves<typeof en>;
type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
type DurationPluralKey = `${"days" | "hours" | "minutes"}_${PluralCategory}`;
type AgoPluralKey = `${"daysAgo" | "hoursAgo" | "minutesAgo"}_${PluralCategory}`;
// i18next plural categories differ by locale, so locale catalogs may add the
// explicit v4 suffixes they need while retaining the canonical fallback keys.
export type TranslationResources = TranslationResourcesBase & {
providerUsage: TranslationResourcesBase["providerUsage"] & {
duration: TranslationResourcesBase["providerUsage"]["duration"] &
Partial<Record<DurationPluralKey, string>>;
timing: TranslationResourcesBase["providerUsage"]["timing"] &
Partial<Record<AgoPluralKey, string>>;
};
};
+66
View File
@@ -544,6 +544,7 @@ export const es: TranslationResources = {
},
},
setup: {
updateHost: "Actualiza el host para ver los detalles de configuración del espacio de trabajo",
descriptor: {
label: "Configuración",
completed: "Configuración completada",
@@ -2093,6 +2094,70 @@ export const es: TranslationResources = {
saveAccessibility: "Guardar comentario de revisión",
},
},
providerUsage: {
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
agentError: "No se pudo cargar el uso: {{reason}}",
pin: "Fijar",
unpin: "Desfijar",
displayAs: "Porcentajes",
displayUsed: "Usado",
displayRemaining: "Restante",
showInSidebar: "Resumen en la barra lateral",
showInSidebarHint: "Los períodos fijados aparecen al pie de la barra lateral",
runToRefresh: "Ejecuta {{command}} para actualizar.",
signInAgain: "Vuelve a iniciar sesión.",
loginRejected: "Inicio de sesión rechazado (HTTP {{status}}). {{remedy}}",
loginExpired: "La sesión caducó {{relativeTime}}. {{remedy}}",
title: "Uso del plan",
refresh: "Actualizar",
refreshAll: "Actualizar todo",
refreshing: "Actualizando...",
loading: "Cargando uso...",
empty: "No hay datos de uso",
errorTitle: "No se pudo cargar el uso",
hostUnavailable: "Conéctate a {{host}} para ver el uso del proveedor",
hostUpgradeRequired: "Actualiza {{host}} para ver el uso del proveedor",
clientUnavailable: "La conexión con el host no está lista",
retry: "Intentar de nuevo",
tooltipLoading: "Cargando uso del plan...",
status: {
error: "Error",
unavailable: "No disponible",
},
values: {
used: "Usado: {{percentage}}",
remaining: "Quedan {{amount}}",
},
labels: {
session: "Sesión",
weekly: "Semanal",
monthly: "Mensual",
codeReview: "Revisión de código",
credits: "Créditos",
monthlyCredits: "Créditos mensuales",
},
duration: {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Última actualización {{relativeTime}}",
noSuccessfulUpdate: "No hay actualizaciones correctas",
resetTimePassed: "Pasó la hora de restablecimiento",
resettingNow: "se restablece ahora",
resetsIn: "se restablece en {{duration}}",
runsOutNow: "se agota ahora",
runsOutIn: "se agota en {{duration}}",
justNow: "ahora mismo",
daysAgo: "hace {{value}} d",
hoursAgo: "hace {{value}} h",
minutesAgo: "hace {{value}} min",
updated: "Actualizado {{relativeTime}}",
},
},
settings: {
title: "Ajustes",
loading: "Cargando configuración...",
@@ -2387,6 +2452,7 @@ export const es: TranslationResources = {
searchPlaceholder: "Buscar atajos",
unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio",
capturePrompt: "Presione el acceso directo...",
captureIndexPrompt: "Termina con un número del 1 al 9.",
unassigned: "Sin asignar",
actions: {
menu: "Acciones para {{name}}",
+67
View File
@@ -543,6 +543,8 @@ export const fr: TranslationResources = {
},
},
setup: {
updateHost:
"Mettez à jour l’hôte pour afficher les détails de configuration de l’espace de travail",
descriptor: {
label: "Configuration",
completed: "Configuration terminée",
@@ -2092,6 +2094,70 @@ export const fr: TranslationResources = {
saveAccessibility: "Enregistrer le commentaire de revue",
},
},
providerUsage: {
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
agentError: "Impossible de charger l’utilisation : {{reason}}",
pin: "Épingler",
unpin: "Désépingler",
displayAs: "Pourcentages",
displayUsed: "Utilisé",
displayRemaining: "Restant",
showInSidebar: "Résumé dans la barre latérale",
showInSidebarHint: "Les périodes épinglées apparaissent en bas de la barre latérale",
runToRefresh: "Exécutez {{command}} pour actualiser.",
signInAgain: "Reconnectez-vous.",
loginRejected: "Connexion refusée (HTTP {{status}}). {{remedy}}",
loginExpired: "Connexion expirée {{relativeTime}}. {{remedy}}",
title: "Utilisation du forfait",
refresh: "Actualiser",
refreshAll: "Tout actualiser",
refreshing: "Actualisation...",
loading: "Chargement de l’utilisation...",
empty: "Aucune donnée d’utilisation",
errorTitle: "Impossible de charger l’utilisation",
hostUnavailable: "Connectez-vous à {{host}} pour voir l’utilisation du fournisseur",
hostUpgradeRequired: "Mettez à jour {{host}} pour voir l’utilisation du fournisseur",
clientUnavailable: "La connexion à l’hôte n’est pas prête",
retry: "Réessayer",
tooltipLoading: "Chargement de l’utilisation du forfait...",
status: {
error: "Erreur",
unavailable: "Indisponible",
},
values: {
used: "Utilisé : {{percentage}}",
remaining: "Il reste {{amount}}",
},
labels: {
session: "Session",
weekly: "Hebdomadaire",
monthly: "Mensuel",
codeReview: "Révision de code",
credits: "Crédits",
monthlyCredits: "Crédits mensuels",
},
duration: {
days: "{{value}} j",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Dernière mise à jour {{relativeTime}}",
noSuccessfulUpdate: "Aucune mise à jour réussie",
resetTimePassed: "Heure de réinitialisation dépassée",
resettingNow: "réinitialisation en cours",
resetsIn: "se réinitialise dans {{duration}}",
runsOutNow: "s’épuise maintenant",
runsOutIn: "s’épuise dans {{duration}}",
justNow: "à l’instant",
daysAgo: "il y a {{value}} j",
hoursAgo: "il y a {{value}} h",
minutesAgo: "il y a {{value}} min",
updated: "Mis à jour {{relativeTime}}",
},
},
settings: {
title: "Paramètres",
loading: "Chargement des paramètres…",
@@ -2387,6 +2453,7 @@ export const fr: TranslationResources = {
searchPlaceholder: "Rechercher des raccourcis",
unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau",
capturePrompt: "Appuyez sur le raccourci…",
captureIndexPrompt: "Terminez par un chiffre de 1 à 9.",
unassigned: "Non attribué",
actions: {
menu: "Actions pour {{name}}",
+66
View File
@@ -544,6 +544,7 @@ export const ja: TranslationResources = {
},
},
setup: {
updateHost: "ワークスペースのセットアップ詳細を表示するにはホストを更新してください",
descriptor: {
label: "セットアップ",
completed: "セットアップ完了",
@@ -2066,6 +2067,70 @@ export const ja: TranslationResources = {
saveAccessibility: "レビューコメントを保存",
},
},
providerUsage: {
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
agentError: "使用状況を読み込めません: {{reason}}",
pin: "ピン留め",
unpin: "ピン留めを解除",
displayAs: "割合",
displayUsed: "使用済み",
displayRemaining: "残り",
showInSidebar: "サイドバーに概要を表示",
showInSidebarHint: "ピン留めした期間をサイドバーの下部に表示します",
runToRefresh: "{{command}} を実行して更新してください。",
signInAgain: "再度サインインしてください。",
loginRejected: "ログインが拒否されました (HTTP {{status}})。{{remedy}}",
loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}",
title: "プラン使用量",
refresh: "更新",
refreshAll: "すべて更新",
refreshing: "更新中...",
loading: "使用状況を読み込み中...",
empty: "使用状況データがありません",
errorTitle: "使用状況を読み込めません",
hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください",
hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください",
clientUnavailable: "ホスト接続の準備ができていません",
retry: "再試行",
tooltipLoading: "プラン使用状況を読み込み中...",
status: {
error: "エラー",
unavailable: "利用不可",
},
values: {
used: "{{percentage}} 使用済み",
remaining: "残り {{amount}}",
},
labels: {
session: "セッション",
weekly: "週間",
monthly: "月間",
codeReview: "コードレビュー",
credits: "クレジット",
monthlyCredits: "月間クレジット",
},
duration: {
days: "{{value}}日",
hours: "{{value}}時間",
minutes: "{{value}}分",
},
timing: {
lastUpdated: "最終更新: {{relativeTime}}",
noSuccessfulUpdate: "正常な更新はまだありません",
resetTimePassed: "リセット時刻を過ぎています",
resettingNow: "まもなくリセット",
resetsIn: "{{duration}}後にリセット",
runsOutNow: "まもなく上限に到達",
runsOutIn: "{{duration}}後に上限に到達",
justNow: "たった今",
daysAgo: "{{value}}日前",
hoursAgo: "{{value}}時間前",
minutesAgo: "{{value}}分前",
updated: "更新: {{relativeTime}}",
},
},
settings: {
title: "設定",
loading: "設定を読み込み中...",
@@ -2354,6 +2419,7 @@ export const ja: TranslationResources = {
searchPlaceholder: "ショートカットを検索",
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
capturePrompt: "ショートカットを押してください...",
captureIndexPrompt: "最後に1〜9の数字を入力してください。",
unassigned: "未設定",
actions: {
menu: "{{name}} のアクション",
+66
View File
@@ -539,6 +539,7 @@ export const ko: TranslationResources = {
},
},
setup: {
updateHost: "작업 공간 설정 세부 정보를 보려면 호스트를 업데이트하세요",
descriptor: {
label: "설정",
completed: "설정 완료",
@@ -2055,6 +2056,70 @@ export const ko: TranslationResources = {
saveAccessibility: "리뷰 댓글 저장",
},
},
providerUsage: {
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
agentError: "사용량을 불러올 수 없습니다: {{reason}}",
pin: "고정",
unpin: "고정 해제",
displayAs: "백분율",
displayUsed: "사용됨",
displayRemaining: "남음",
showInSidebar: "사이드바에 요약 표시",
showInSidebarHint: "고정된 기간이 사이드바 하단에 표시됩니다",
runToRefresh: "{{command}} 명령을 실행하여 새로 고치세요.",
signInAgain: "다시 로그인하세요.",
loginRejected: "로그인이 거부되었습니다 (HTTP {{status}}). {{remedy}}",
loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}",
title: "플랜 사용량",
refresh: "새로 고침",
refreshAll: "모두 새로 고침",
refreshing: "새로 고치는 중...",
loading: "사용량 불러오는 중...",
empty: "사용량 데이터가 없습니다",
errorTitle: "사용량을 불러올 수 없습니다",
hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요",
hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요",
clientUnavailable: "호스트 연결이 준비되지 않았습니다",
retry: "다시 시도",
tooltipLoading: "플랜 사용량 불러오는 중...",
status: {
error: "오류",
unavailable: "사용할 수 없음",
},
values: {
used: "{{percentage}} 사용됨",
remaining: "{{amount}} 남음",
},
labels: {
session: "세션",
weekly: "주간",
monthly: "월간",
codeReview: "코드 검토",
credits: "크레딧",
monthlyCredits: "월간 크레딧",
},
duration: {
days: "{{value}}일",
hours: "{{value}}시간",
minutes: "{{value}}분",
},
timing: {
lastUpdated: "마지막 업데이트: {{relativeTime}}",
noSuccessfulUpdate: "성공한 업데이트가 없습니다",
resetTimePassed: "초기화 시간이 지났습니다",
resettingNow: "지금 재설정 중",
resetsIn: "{{duration}} 후 재설정",
runsOutNow: "지금 한도에 도달",
runsOutIn: "{{duration}} 후 한도에 도달",
justNow: "방금",
daysAgo: "{{value}}일 전",
hoursAgo: "{{value}}시간 전",
minutesAgo: "{{value}}분 전",
updated: "{{relativeTime}}에 업데이트됨",
},
},
settings: {
title: "설정",
loading: "설정 불러오는 중...",
@@ -2343,6 +2408,7 @@ export const ko: TranslationResources = {
searchPlaceholder: "검색 단축키",
unavailableOnMobile: "키보드 단축키는 데스크톱에서만 사용할 수 있습니다",
capturePrompt: "단축키를 누르세요...",
captureIndexPrompt: "마지막에 1부터 9까지의 숫자를 입력하세요.",
unassigned: "설정되지 않음",
actions: {
menu: "{{name}} 작업",
+66
View File
@@ -543,6 +543,7 @@ export const ptBR: TranslationResources = {
},
},
setup: {
updateHost: "Atualize o host para ver os detalhes da configuração do espaço de trabalho",
descriptor: {
label: "Configuração",
completed: "Configuração concluída",
@@ -2078,6 +2079,70 @@ export const ptBR: TranslationResources = {
saveAccessibility: "Salvar comentário de revisão",
},
},
providerUsage: {
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
agentError: "Não foi possível carregar o uso: {{reason}}",
pin: "Fixar",
unpin: "Desafixar",
displayAs: "Porcentagens",
displayUsed: "Usado",
displayRemaining: "Restante",
showInSidebar: "Resumo na barra lateral",
showInSidebarHint: "Os períodos fixados aparecem no rodapé da barra lateral",
runToRefresh: "Execute {{command}} para atualizar.",
signInAgain: "Entre novamente.",
loginRejected: "Login rejeitado (HTTP {{status}}). {{remedy}}",
loginExpired: "O login expirou {{relativeTime}}. {{remedy}}",
title: "Uso do plano",
refresh: "Atualizar",
refreshAll: "Atualizar tudo",
refreshing: "Atualizando...",
loading: "Carregando uso...",
empty: "Sem dados de uso",
errorTitle: "Não foi possível carregar o uso",
hostUnavailable: "Conecte-se a {{host}} para ver o uso do provedor",
hostUpgradeRequired: "Atualize {{host}} para ver o uso do provedor",
clientUnavailable: "A conexão com o host não está pronta",
retry: "Tentar novamente",
tooltipLoading: "Carregando uso do plano...",
status: {
error: "Erro",
unavailable: "Indisponível",
},
values: {
used: "{{percentage}} usado",
remaining: "{{amount}} restantes",
},
labels: {
session: "Sessão",
weekly: "Semanal",
monthly: "Mensal",
codeReview: "Revisão de código",
credits: "Créditos",
monthlyCredits: "Créditos mensais",
},
duration: {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Última atualização {{relativeTime}}",
noSuccessfulUpdate: "Nenhuma atualização bem-sucedida",
resetTimePassed: "O horário de redefinição passou",
resettingNow: "redefinindo agora",
resetsIn: "redefine em {{duration}}",
runsOutNow: "esgota agora",
runsOutIn: "esgota em {{duration}}",
justNow: "agora mesmo",
daysAgo: "há {{value}} d",
hoursAgo: "há {{value}} h",
minutesAgo: "há {{value}} min",
updated: "Atualizado {{relativeTime}}",
},
},
settings: {
title: "Configurações",
loading: "Carregando configurações...",
@@ -2368,6 +2433,7 @@ export const ptBR: TranslationResources = {
searchPlaceholder: "Pesquisar atalhos",
unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop",
capturePrompt: "Pressione o atalho...",
captureIndexPrompt: "Termine com um número de 1 a 9.",
unassigned: "Não definido",
actions: {
menu: "Ações para {{name}}",
+66
View File
@@ -543,6 +543,7 @@ export const ru: TranslationResources = {
},
},
setup: {
updateHost: "Обновите хост, чтобы просмотреть сведения о настройке рабочего пространства",
descriptor: {
label: "Настройка",
completed: "Настройка завершена",
@@ -2078,6 +2079,70 @@ export const ru: TranslationResources = {
saveAccessibility: "Сохранить комментарий к ревью",
},
},
providerUsage: {
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
agentError: "Не удалось загрузить использование: {{reason}}",
pin: "Закрепить",
unpin: "Открепить",
displayAs: "Проценты",
displayUsed: "Использовано",
displayRemaining: "Осталось",
showInSidebar: "Сводка на боковой панели",
showInSidebarHint: "Закреплённые периоды отображаются внизу боковой панели",
runToRefresh: "Выполните {{command}} для обновления.",
signInAgain: "Войдите снова.",
loginRejected: "Вход отклонён (HTTP {{status}}). {{remedy}}",
loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}",
title: "Использование плана",
refresh: "Обновить",
refreshAll: "Обновить всё",
refreshing: "Обновление...",
loading: "Загрузка данных об использовании...",
empty: "Нет данных об использовании",
errorTitle: "Не удалось загрузить данные об использовании",
hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера",
hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера",
clientUnavailable: "Соединение с хостом не готово",
retry: "Повторить",
tooltipLoading: "Загрузка использования плана...",
status: {
error: "Ошибка",
unavailable: "Недоступно",
},
values: {
used: "Использовано: {{percentage}}",
remaining: "Осталось: {{amount}}",
},
labels: {
session: "Сессия",
weekly: "Еженедельно",
monthly: "Ежемесячно",
codeReview: "Проверка кода",
credits: "Кредиты",
monthlyCredits: "Ежемесячные кредиты",
},
duration: {
days: "{{value}} дн.",
hours: "{{value}} ч",
minutes: "{{value}} мин",
},
timing: {
lastUpdated: "Последнее обновление {{relativeTime}}",
noSuccessfulUpdate: "Успешных обновлений нет",
resetTimePassed: "Время сброса прошло",
resettingNow: "сброс сейчас",
resetsIn: "сброс через {{duration}}",
runsOutNow: "закончится сейчас",
runsOutIn: "закончится через {{duration}}",
justNow: "только что",
daysAgo: "{{value}} дн. назад",
hoursAgo: "{{value}} ч назад",
minutesAgo: "{{value}} мин назад",
updated: "Обновлено {{relativeTime}}",
},
},
settings: {
title: "Настройки",
loading: "Загрузка настроек...",
@@ -2373,6 +2438,7 @@ export const ru: TranslationResources = {
searchPlaceholder: "Поиск сочетаний клавиш",
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
capturePrompt: "Нажмите сочетание клавиш...",
captureIndexPrompt: "Завершите цифрой от 1 до 9.",
unassigned: "Не задано",
actions: {
menu: "Действия для {{name}}",
+66
View File
@@ -538,6 +538,7 @@ export const zhCN: TranslationResources = {
},
},
setup: {
updateHost: "更新主机以查看工作区设置详情",
descriptor: {
label: "Setup",
completed: "Setup 已完成",
@@ -2023,6 +2024,70 @@ export const zhCN: TranslationResources = {
saveAccessibility: "保存 review 评论",
},
},
providerUsage: {
options: "设置",
refreshFailed: "无法刷新用量",
noHosts: "没有已连接的主机",
agentError: "无法加载用量:{{reason}}",
pin: "固定",
unpin: "取消固定",
displayAs: "百分比",
displayUsed: "已使用",
displayRemaining: "剩余",
showInSidebar: "在侧边栏显示摘要",
showInSidebarHint: "固定的时间窗口显示在侧边栏底部",
runToRefresh: "运行 {{command}} 以刷新。",
signInAgain: "请重新登录。",
loginRejected: "登录被拒绝(HTTP {{status}})。{{remedy}}",
loginExpired: "登录已于{{relativeTime}}过期。{{remedy}}",
title: "套餐用量",
refresh: "刷新",
refreshAll: "全部刷新",
refreshing: "刷新中...",
loading: "正在加载用量...",
empty: "暂无用量数据",
errorTitle: "无法加载用量",
hostUnavailable: "连接到 {{host}} 以查看提供商用量",
hostUpgradeRequired: "更新 {{host}} 以查看提供商用量",
clientUnavailable: "主机连接尚未就绪",
retry: "重试",
tooltipLoading: "正在加载套餐用量...",
status: {
error: "错误",
unavailable: "不可用",
},
values: {
used: "已使用 {{percentage}}",
remaining: "剩余 {{amount}}",
},
labels: {
session: "会话",
weekly: "每周",
monthly: "每月",
codeReview: "代码审查",
credits: "积分",
monthlyCredits: "每月积分",
},
duration: {
days: "{{value}}天",
hours: "{{value}}小时",
minutes: "{{value}}分钟",
},
timing: {
lastUpdated: "上次更新于 {{relativeTime}}",
noSuccessfulUpdate: "尚无成功更新",
resetTimePassed: "重置时间已过",
resettingNow: "正在重置",
resetsIn: "将在 {{duration}}后重置",
runsOutNow: "即将用尽",
runsOutIn: "将在 {{duration}}后用尽",
justNow: "刚刚",
daysAgo: "{{value}}天前",
hoursAgo: "{{value}}小时前",
minutesAgo: "{{value}}分钟前",
updated: "更新于 {{relativeTime}}",
},
},
settings: {
title: "设置",
loading: "正在加载设置...",
@@ -2305,6 +2370,7 @@ export const zhCN: TranslationResources = {
searchPlaceholder: "搜索快捷键",
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
capturePrompt: "按下快捷键...",
captureIndexPrompt: "请以 1 到 9 的数字结尾。",
unassigned: "未设置",
actions: {
menu: "{{name}} 的操作",
@@ -4,7 +4,9 @@ import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
DEFAULT_BINDINGS,
normalizeCapturedShortcutCombo,
isShortcutCaptureComplete,
getBindingIdForAction,
getDefaultKeysForAction,
getWorkspaceIndexJumpModifierKey,
@@ -1390,3 +1392,22 @@ describe("number-group shortcut capture", () => {
expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+9")).toBe("Ctrl+Digit");
});
});
describe("shortcut capture completion", () => {
const indexBindings = DEFAULT_BINDINGS.filter((binding) => binding.payload?.type === "index");
it.each(indexBindings)("requires a final 1–9 number for $id", ({ id }) => {
for (const combos of [[], ["Ctrl+K"], ["Ctrl+0"], ["Ctrl+Digit", "Ctrl+K"]]) {
expect(isShortcutCaptureComplete(id, combos)).toBe(false);
}
for (let index = 1; index <= 9; index++) {
const combo = normalizeCapturedShortcutCombo(id, `Ctrl+${index}`);
expect(isShortcutCaptureComplete(id, [combo])).toBe(true);
expect(isShortcutCaptureComplete(id, ["Ctrl+K", combo])).toBe(true);
}
});
it("keeps ordinary shortcuts available without a number", () => {
expect(isShortcutCaptureComplete("show-shortcuts", [])).toBe(false);
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+K"])).toBe(true);
expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+0"])).toBe(true);
});
});
@@ -1217,6 +1217,14 @@ export function normalizeCapturedShortcutCombo(bindingId: string, combo: string)
return combo.replace(/(^|\+)[1-9]$/, "$1Digit");
}
/** Index navigation needs a final number; earlier combos may be any chord prefix. */
export function isShortcutCaptureComplete(bindingId: string, combos: readonly string[]): boolean {
const lastCombo = combos.at(-1);
if (!lastCombo) return false;
const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId);
return binding?.payload?.type !== "index" || /(^|\+)(Digit|[1-9])$/.test(lastCombo);
}
export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] {
return DEFAULT_BINDINGS.map(function (binding) {
const override = overrides[binding.id];
+60 -47
View File
@@ -1,7 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
@@ -17,6 +17,9 @@ import {
type WorkspaceSetupSnapshot,
} from "@/stores/workspace-setup-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { useHostFeature } from "@/runtime/host-features";
function useSetupPanelDescriptor(
@@ -24,13 +27,11 @@ function useSetupPanelDescriptor(
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const { t } = useTranslation();
const key = buildWorkspaceTabPersistenceKey({
serverId: context.serverId,
workspaceId: target.workspaceId,
});
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const fields = useWorkspaceFields(context.serverId, target.workspaceId, (workspace) => ({
status: workspace.setupStatus ?? null,
}));
if (snapshot?.status === "completed") {
if (fields?.status === "completed") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.completed"),
@@ -41,7 +42,7 @@ function useSetupPanelDescriptor(
};
}
if (snapshot?.status === "failed") {
if (fields?.status === "failed") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.failed"),
@@ -52,7 +53,7 @@ function useSetupPanelDescriptor(
};
}
if (snapshot?.status === "blocked") {
if (fields?.status === "blocked") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.blocked"),
@@ -69,7 +70,7 @@ function useSetupPanelDescriptor(
tooltip: t("workspace.setup.descriptor.workspace"),
titleState: "ready",
icon: SquareTerminal,
statusBucket: snapshot?.status === "running" ? "running" : null,
statusBucket: fields?.status === "running" ? "running" : null,
};
}
@@ -216,33 +217,34 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
}
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string, enabled: boolean) {
const client = useHostRuntimeClient(serverId);
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const requestedRef = useRef(false);
const applySnapshot = useWorkspaceSetupStore((state) => state.applySnapshot);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (snapshot || requestedRef.current || !client) return;
requestedRef.current = true;
client
.fetchWorkspaceSetupStatus(workspaceId)
.then((response) => {
if (response.snapshot) {
upsertProgress({
serverId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
}
return;
})
.catch(() => {
// Server may not support this yet — ignore.
});
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
return snapshot;
if (!client || !enabled) return;
setError(null);
const subscription = client.observeWorkspaceSetup(workspaceId);
const detach = subscription.subscribe({
snapshot: (response) => {
applySnapshot({ serverId, workspaceId, snapshot: response.snapshot });
},
update: (message) => {
if (message.type === "workspace_setup_progress")
applySnapshot({ serverId, workspaceId, snapshot: message.payload });
},
error: (failure) => setError(failure instanceof Error ? failure.message : String(failure)),
});
return () => {
detach();
void subscription
.release()
.catch((failure) => console.warn("Failed to release setup observation", failure));
};
}, [client, enabled, serverId, applySnapshot, workspaceId]);
return { snapshot, error };
}
function useExpandedSetupCommands() {
@@ -273,7 +275,14 @@ function SetupPanel() {
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
const isActive = useRetainedPanelActive();
const isVisible = useAppActivelyVisible();
const supportsObservation = useHostFeature(serverId, "workspaceSetupObservation");
const { snapshot, error: observationError } = useWorkspaceSetupSnapshot(
serverId,
target.workspaceId,
isActive && isVisible && supportsObservation,
);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
@@ -306,19 +315,23 @@ function SetupPanel() {
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
{statusLabel}
</Text>
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
{!supportsObservation && <Alert variant="warning" title={t("workspace.setup.updateHost")} />}
{observationError && <Alert variant="error" title={observationError} />}
{supportsObservation && !observationError && (
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
)}
</ScrollView>
);
}
@@ -1,6 +1,10 @@
import { z } from "zod";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@getpaseo/plugin";
import {
PluginAttachmentItemSchema,
type PluginAttachmentItem,
type PluginSerializableIcon,
} from "@getpaseo/plugin";
import type { UserComposerAttachment } from "@/attachments/types";
export const PluginResourceComposerAttachmentSchema = z.object({
@@ -8,7 +12,8 @@ export const PluginResourceComposerAttachmentSchema = z.object({
pluginId: z.string().min(1),
sourceId: z.string().min(1),
sourceTitle: z.string().min(1),
sourceIcon: z.string().min(1),
// Validated when the source registered; a draft keeps the icon its source had then.
sourceIcon: z.union([z.string().min(1), z.object({ svg: z.string().min(1) })]),
item: PluginAttachmentItemSchema,
});
@@ -20,7 +25,7 @@ export interface PluginResourceSourceIdentity {
pluginId: string;
sourceId: string;
sourceTitle: string;
sourceIcon: string;
sourceIcon: PluginSerializableIcon;
}
export function createPluginResourceAttachment(
+14 -14
View File
@@ -2,15 +2,18 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin";
import type {
PluginAttachmentItem,
PluginAttachmentSourceContribution,
PluginSerializableIcon,
} from "@getpaseo/plugin";
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import { PluginIconView } from "../icons";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
@@ -19,8 +22,8 @@ const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
function SourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
@@ -145,15 +148,12 @@ export function usePluginAttachmentPicker(
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => {
const Icon = resolvePluginIcon(source.icon);
return {
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
sources.map(({ key, source }) => ({
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
})),
[sources],
);
if (!active) return { menuItems, picker: null };
+6 -12
View File
@@ -3,19 +3,14 @@ import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import type { ComposerAttachment } from "@/attachments/types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import type { PluginSerializableIcon } from "@getpaseo/plugin";
import { PluginIconView } from "../icons";
import type { PluginResourceComposerAttachment } from "./model";
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ResourceIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.sm} color={color} />;
}
const ThemedResourceIcon = withUnistyles(ResourceIcon);
@@ -39,12 +34,11 @@ export function PluginResourceAttachmentPill({
openLabel,
removeLabel,
}: PluginResourceAttachmentPillProps) {
const Icon = resolvePluginIcon(attachment.sourceIcon);
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
const icon = useMemo(
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
() => <ThemedResourceIcon icon={attachment.sourceIcon} uniProps={iconColorMapping} />,
[attachment.sourceIcon],
);
return (
<AttachmentPill
@@ -30,8 +30,9 @@ function installation(): InstalledPlugin {
function store() {
return new PluginButtonStore({
validateIconName(name) {
if (name !== "Scan") throw new Error("Unknown icon");
parseIcon(icon) {
if (icon !== "Scan") throw new Error("Unknown icon");
return icon;
},
});
}
+4 -2
View File
@@ -1,4 +1,6 @@
import { resolvePluginIcon } from "../icons";
import { parsePluginIcon } from "../icons";
import { PluginButtonStore } from "./model";
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
export const pluginButtonStore = new PluginButtonStore({
parseIcon: (icon) => parsePluginIcon("Plugin button", icon),
});
@@ -1,7 +1,8 @@
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
export interface ButtonValidation {
validateIconName(name: string): void;
/** Returns the normalized icon, or throws naming why the icon is unusable. */
parseIcon(icon: PluginButtonIcon): PluginButtonIcon;
}
export function requireButtonId(value: string): string {
@@ -29,12 +30,6 @@ function isComponent(value: unknown): boolean {
);
}
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
if (typeof icon === "string") validation.validateIconName(icon);
else if (!isComponent(icon))
throw new Error("Plugin button icon must be a Lucide name or component");
}
function validateBehavior(
behavior: PluginButtonBehavior,
validation: ButtonValidation,
@@ -57,9 +52,9 @@ function validateBehavior(
ids.add(item.id);
if (item.kind === "separator") return { ...item };
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
if (item.icon !== undefined) validateIcon(item.icon, validation);
return {
...item,
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
title: requireText(item.title, "menu item title"),
visible: optionalBoolean(item.visible, true),
disabled: optionalBoolean(item.disabled, false),
@@ -83,9 +78,9 @@ export function validateButton(
button: PluginButton,
validation: ButtonValidation,
): ResolvedPluginButton {
validateIcon(button.icon, validation);
return {
...button,
icon: validation.parseIcon(button.icon),
title: requireText(button.title, "title"),
label: button.label === undefined ? undefined : requireText(button.label, "label"),
visible: optionalBoolean(button.visible, true),
+3 -15
View File
@@ -32,7 +32,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { Icon } from "../icons";
import { pluginIconComponent } from "../icons";
import {
PluginEnvironmentProvider,
PluginPopoverContent,
@@ -79,6 +79,7 @@ function ButtonEnvironment({ view, children }: { view: ButtonView; children: Rea
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
const color = view.props.theme.colors.foregroundMuted;
const size = view.entry.placement === "composer" ? 14 : 16;
const Icon = pluginIconComponent(icon);
return (
<View
style={styles.icon}
@@ -87,24 +88,11 @@ function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{typeof icon === "string" ? (
<Icon name={icon} size={size} color={color} />
) : (
renderCustomIcon(icon, view, size, color)
)}
<Icon {...view.props} size={size} color={color} />
</View>
);
}
function renderCustomIcon(
CustomIcon: Exclude<PluginButtonIcon, string>,
view: ButtonView,
size: number,
color: string,
) {
return <CustomIcon {...view.props} size={size} color={color} />;
}
function pressButton(view: ButtonView, path: readonly string[]) {
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
view.toast.error(error instanceof Error ? error.message : String(error));
@@ -1,7 +1,7 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
import { resolvePluginIcon } from "../icons";
import { pluginIconComponent } from "../icons";
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
import type { InstalledPlugin } from "../types";
import { createPluginCapabilities, type PluginNavigation } from "../actions";
@@ -83,7 +83,7 @@ export function buildPluginCommandCenterContributions(
kind: "action",
title: item.title,
sectionTitle: plugin.id,
icon: getCommandCenterIcon(resolvePluginIcon(item.icon)),
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
},
run,
});
+66
View File
@@ -381,6 +381,72 @@ describe("evaluatePluginClientBundle", () => {
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
});
it("registers inline SVG and component icons", () => {
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function ReviewPanel() { return null; }
function BadgeIcon() { return null; }
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: { svg: ${JSON.stringify(` ${svg}\n`)} },
context: "workspace",
Component: ReviewPanel,
});
plugin.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: BadgeIcon,
context: "global",
onSelect() {},
});
plugin.addAttachmentSource({
id: "issues",
title: "Issues",
icon: { svg: ${JSON.stringify(svg)} },
pickerTitle: "Attach issue",
searchPlaceholder: "Search",
search: { name: "issues.search", input: {}, output: {} },
});
`),
);
expect(plugin.workspacePanels[0]?.icon).toEqual({ svg });
expect(plugin.commandCenterItems[0]?.icon).toHaveProperty("name", "BadgeIcon");
expect(plugin.attachmentSources[0]?.icon).toEqual({ svg });
});
it.each([
[
"workspace panel",
`plugin.addWorkspacePanel({ id: "review", title: "Review", icon: ICON, context: "workspace", Component() { return null; } })`,
"Workspace panel review has an invalid icon: script elements are not allowed",
],
[
"Command Center item",
`plugin.addCommandCenterItem({ id: "review", title: "Review", icon: ICON, context: "global", onSelect() {} })`,
"Command Center item review has an invalid icon: script elements are not allowed",
],
[
"settings screen",
`plugin.addSettingsScreen({ id: "review", title: "Review", icon: ICON, Component() { return null; } })`,
"Settings screen review has an invalid icon: script elements are not allowed",
],
[
"attachment source",
`plugin.addAttachmentSource({ id: "review", title: "Review", icon: ICON, pickerTitle: "Pick", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } })`,
"Attachment source review has an invalid icon: script elements are not allowed",
],
])("fails the plugin when a %s registers an unsafe SVG", (_kind, registration, message) => {
const icon = JSON.stringify({ svg: "<svg><script>run()</script></svg>" });
expect(() =>
evaluatePluginClientBundle("review", bundle(registration.replace("ICON", icon))),
).toThrow(message);
});
it("normalizes and validates workspace panel locations", () => {
const plugin = evaluatePluginClientBundle(
"review",
+10 -15
View File
@@ -33,7 +33,7 @@ import {
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
@@ -199,9 +199,9 @@ export function runPluginClientBundle(
if (settingsScreenIds.has(screenId))
throw new Error(`Duplicate settings screen: ${screenId}`);
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
resolvePluginIcon(contribution.icon);
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
settingsScreenIds.add(screenId);
return register(collector.settingsScreens, { ...contribution, id: screenId }, () =>
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
settingsScreenIds.delete(screenId),
);
},
@@ -241,12 +241,10 @@ export function runPluginClientBundle(
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
const legacy = {
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
surface: requireId(contribution.surface, "sidebar surface id"),
};
// Shares the header's ids: the sidebar orders both kinds of item under one key.
@@ -262,16 +260,14 @@ export function runPluginClientBundle(
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`);
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
}
if (typeof contribution.Component !== "function") {
throw new Error(`Workspace panel ${normalizedId} is not a component`);
}
resolvePluginIcon(icon);
const locations = normalizePanelLocations(normalizedId, contribution.locations);
workspacePanelIds.add(normalizedId);
return register(
@@ -292,9 +288,8 @@ export function runPluginClientBundle(
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`);
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
if (
contribution.context !== "global" &&
contribution.context !== "workspace" &&
@@ -305,7 +300,6 @@ export function runPluginClientBundle(
if (typeof contribution.onSelect !== "function") {
throw new Error(`Command Center item ${normalizedId} has no callback`);
}
resolvePluginIcon(icon);
commandCenterItemIds.add(normalizedId);
return register(
collector.commandCenterItems,
@@ -350,18 +344,19 @@ export function runPluginClientBundle(
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
const icon = parseSerializablePluginIcon(
`Attachment source ${normalizedId}`,
contribution.icon,
);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
return register(
collector.attachmentSources,
-18
View File
@@ -1,18 +0,0 @@
import { Settings } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { Icon } from "./icons";
describe("Icon", () => {
it("renders a host Lucide icon with the requested presentation", () => {
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders nothing for an unknown icon name", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { Settings } from "lucide-react-native";
import React, { isValidElement, type ReactElement, type ReactNode } from "react";
import { SvgXml } from "react-native-svg";
import { describe, expect, it } from "vitest";
import type { PluginIconProps } from "@getpaseo/plugin";
import { Icon, parsePluginIcon, parseSerializablePluginIcon, PluginIconView } from "./icons";
const SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const SVG_ICON = { svg: SVG };
const GLYPHS = new Set<unknown>([Settings, SvgXml]);
/** Expands function components down to what reaches the screen: a Lucide glyph, SvgXml, or DOM. */
function draw(node: ReactNode): ReactElement | null {
if (!isValidElement(node)) return null;
const { type } = node;
if (typeof type !== "function" || GLYPHS.has(type)) return node;
return draw((type as (props: unknown) => ReactNode)(node.props));
}
function BadgeIcon({ size, color }: PluginIconProps) {
return <span data-size={size} data-color={color} />;
}
describe("PluginIconView", () => {
it("draws a Lucide name as that Lucide icon", () => {
expect(draw(<PluginIconView icon="Settings" size={18} color="#123456" />)).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("draws inline SVG sized to the surface, with color reaching currentColor", () => {
expect(draw(<PluginIconView icon={SVG_ICON} size={18} color="#123456" />)).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, height: 18, color: "#123456" },
});
});
it("draws a component with the surface's size and color", () => {
expect(draw(<PluginIconView icon={BadgeIcon} size={18} color="#123456" />)).toMatchObject({
type: "span",
props: { "data-size": 18, "data-color": "#123456" },
});
});
});
describe("parsePluginIcon", () => {
it("normalizes each icon form", () => {
expect(parsePluginIcon("Workspace panel files", " Settings ")).toBe("Settings");
expect(parsePluginIcon("Workspace panel files", { svg: `\n${SVG}\n` })).toEqual({ svg: SVG });
expect(parsePluginIcon("Workspace panel files", BadgeIcon)).toBe(BadgeIcon);
});
it.each([
["", "Workspace panel files has no icon"],
[
"NotALucideIcon",
'Workspace panel files has an invalid icon: unknown Lucide icon "NotALucideIcon"',
],
[
{ svg: "<svg><script>run()</script></svg>" },
"Workspace panel files has an invalid icon: script elements are not allowed",
],
[{ svg: "hello" }, "Workspace panel files has an invalid icon: not an SVG document"],
[
{ path: "icon.svg" },
"Workspace panel files has an invalid icon: expected a Lucide icon name, { svg }, or a component",
],
])("rejects %j", (icon, message) => {
expect(() => parsePluginIcon("Workspace panel files", icon as never)).toThrow(message);
});
it("refuses a component where the icon is stored as data", () => {
expect(() =>
parseSerializablePluginIcon("Attachment source issues", BadgeIcon as never),
).toThrow(
"Attachment source issues has an invalid icon: expected a Lucide icon name or { svg }",
);
});
});
describe("Icon", () => {
it("renders a host Lucide icon by name with the requested presentation", () => {
expect(draw(Icon({ name: "Settings", size: 18, color: "#123456" }))).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders a plugin's own SVG through the icon prop", () => {
expect(draw(Icon({ icon: { svg: SVG }, size: 18, color: "#123456" }))).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, color: "#123456" },
});
});
it("renders nothing for an unusable icon", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
expect(Icon({ icon: { svg: '<svg onload="run()"></svg>' } })).toBeNull();
});
});

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