mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-20 00:02:54 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d61cca50c | ||
|
|
0eee4fd9b0 | ||
|
|
69ef741a73 | ||
|
|
90f3cab585 | ||
|
|
fc775b6db5 | ||
|
|
71e7cf6ff8 | ||
|
|
eda3e4318f | ||
|
|
91154ba4e8 | ||
|
|
05920a236c | ||
|
|
dd244c076d | ||
|
|
1c8c68f93e | ||
|
|
19749baeec | ||
|
|
e760764b37 | ||
|
|
b6dde61a7f | ||
|
|
1e0e56c721 | ||
|
|
c575464e8f | ||
|
|
9a0b95aa10 | ||
|
|
9be1547741 | ||
|
|
cafe4b01c8 | ||
|
|
8dd3e2e34e | ||
|
|
b8bcd204a9 | ||
|
|
d61dcaf1df | ||
|
|
644d66ba4f |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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, ... }:
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
|
||||
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
|
||||
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
|
||||
Generated
+28
-28
@@ -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
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
+47
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
-1
@@ -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
-1
@@ -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", () => {
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} のアクション",
|
||||
|
||||
@@ -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}} 작업",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user