mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952e9e2974 | ||
|
|
887845418e | ||
|
|
f7c4ef774d |
-192
@@ -84,198 +84,6 @@
|
|||||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
|
||||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "@tanstack/react-query",
|
|
||||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
|
||||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/data/**/*.{ts,tsx}",
|
|
||||||
"packages/app/src/**/*.test.{ts,tsx}",
|
|
||||||
"packages/app/src/**/*.spec.{ts,tsx}"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/assistant-file-links/use-file-link.ts",
|
|
||||||
"packages/app/src/components/message.tsx",
|
|
||||||
"packages/app/src/components/worktree-setup-callout-source.tsx",
|
|
||||||
"packages/app/src/desktop/hooks/use-daemon-status.ts",
|
|
||||||
"packages/app/src/desktop/hooks/use-install-status.ts",
|
|
||||||
"packages/app/src/desktop/settings/desktop-settings.ts",
|
|
||||||
"packages/app/src/git/pull-request-panel/use-data.ts",
|
|
||||||
"packages/app/src/git/use-github-search-query.ts",
|
|
||||||
"packages/app/src/git/use-pr-status-query.ts",
|
|
||||||
"packages/app/src/git/use-status-query.ts",
|
|
||||||
"packages/app/src/hooks/use-archive-agent.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-autocomplete.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-commands-query.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-history.ts",
|
|
||||||
"packages/app/src/hooks/use-branch-switcher.ts",
|
|
||||||
"packages/app/src/hooks/use-changes-preferences/index.ts",
|
|
||||||
"packages/app/src/hooks/use-draft-agent-features.ts",
|
|
||||||
"packages/app/src/hooks/use-form-preferences.ts",
|
|
||||||
"packages/app/src/hooks/use-is-local-daemon.ts",
|
|
||||||
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
|
|
||||||
"packages/app/src/hooks/use-preferred-editor.ts",
|
|
||||||
"packages/app/src/hooks/use-project-icon-query.ts",
|
|
||||||
"packages/app/src/hooks/use-settings/index.ts",
|
|
||||||
"packages/app/src/panels/terminal-panel.tsx",
|
|
||||||
"packages/app/src/projects/project-icons.ts",
|
|
||||||
"packages/app/src/provider-usage/use-provider-usage.ts",
|
|
||||||
"packages/app/src/screens/project-settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
|
|
||||||
"packages/app/src/workspace/desktop-open-targets.ts"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/app/_layout.tsx",
|
|
||||||
"packages/app/src/app/pair-scan.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/agent-list.tsx",
|
|
||||||
"packages/app/src/components/agent-status-dot.tsx",
|
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
|
||||||
"packages/app/src/components/browser-pane.electron.tsx",
|
|
||||||
"packages/app/src/components/browser-pane.tsx",
|
|
||||||
"packages/app/src/components/browser-pane.web.tsx",
|
|
||||||
"packages/app/src/components/command-center.tsx",
|
|
||||||
"packages/app/src/components/context-window-meter.tsx",
|
|
||||||
"packages/app/src/components/dictation-controls.tsx",
|
|
||||||
"packages/app/src/components/download-toast.tsx",
|
|
||||||
"packages/app/src/components/draggable-list.native.tsx",
|
|
||||||
"packages/app/src/components/explorer-sidebar.tsx",
|
|
||||||
"packages/app/src/components/headers/back-header.tsx",
|
|
||||||
"packages/app/src/components/headers/menu-header.tsx",
|
|
||||||
"packages/app/src/components/headers/screen-header.tsx",
|
|
||||||
"packages/app/src/components/host-status-dot.tsx",
|
|
||||||
"packages/app/src/components/hosts/host-picker.tsx",
|
|
||||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
|
||||||
"packages/app/src/components/left-sidebar.tsx",
|
|
||||||
"packages/app/src/components/pair-link-modal.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/quitting-overlay.tsx",
|
|
||||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
|
||||||
"packages/app/src/components/resize-handle.tsx",
|
|
||||||
"packages/app/src/components/rewind/rewind-menu.tsx",
|
|
||||||
"packages/app/src/components/settings-textarea.tsx",
|
|
||||||
"packages/app/src/components/sidebar-callout.tsx",
|
|
||||||
"packages/app/src/components/split-container.tsx",
|
|
||||||
"packages/app/src/components/split-drop-zone.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/ui/alert.tsx",
|
|
||||||
"packages/app/src/components/ui/autocomplete.tsx",
|
|
||||||
"packages/app/src/components/ui/combobox.tsx",
|
|
||||||
"packages/app/src/components/ui/context-menu.tsx",
|
|
||||||
"packages/app/src/components/ui/dropdown-menu.tsx",
|
|
||||||
"packages/app/src/components/ui/external-link.tsx",
|
|
||||||
"packages/app/src/components/volume-meter.tsx",
|
|
||||||
"packages/app/src/components/web-desktop-scrollbar.tsx",
|
|
||||||
"packages/app/src/components/welcome-screen.tsx",
|
|
||||||
"packages/app/src/composer/agent-controls/index.tsx",
|
|
||||||
"packages/app/src/composer/agent-controls/mode-control.tsx",
|
|
||||||
"packages/app/src/constants/layout.ts",
|
|
||||||
"packages/app/src/desktop/components/desktop-permission-row.tsx",
|
|
||||||
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
|
|
||||||
"packages/app/src/desktop/components/desktop-updates-section.tsx",
|
|
||||||
"packages/app/src/desktop/components/integrations-section.tsx",
|
|
||||||
"packages/app/src/desktop/updates/update-callout-source.tsx",
|
|
||||||
"packages/app/src/git/actions-split-button.tsx",
|
|
||||||
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
|
|
||||||
"packages/app/src/hosts/host-chooser.tsx",
|
|
||||||
"packages/app/src/screens/open-project-screen.tsx",
|
|
||||||
"packages/app/src/screens/projects-screen.tsx",
|
|
||||||
"packages/app/src/screens/sessions-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings/host-page.tsx",
|
|
||||||
"packages/app/src/screens/settings/providers-section.tsx",
|
|
||||||
"packages/app/src/screens/settings/settings-group.tsx",
|
|
||||||
"packages/app/src/screens/startup-splash-screen.tsx",
|
|
||||||
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "@tanstack/react-query",
|
|
||||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
|
||||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/components/file-explorer-pane.tsx",
|
|
||||||
"packages/app/src/components/file-pane.tsx",
|
|
||||||
"packages/app/src/components/import-session-sheet.tsx",
|
|
||||||
"packages/app/src/components/project-picker-modal.tsx",
|
|
||||||
"packages/app/src/desktop/components/pair-device-section.tsx",
|
|
||||||
"packages/app/src/screens/new-workspace-screen.tsx"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files": ["**/e2e/fixtures.ts"],
|
"files": ["**/e2e/fixtures.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
+2
-12
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.104 - 2026-07-08
|
## 0.1.104-beta.4 - 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -10,27 +10,17 @@
|
|||||||
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
|
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
|
||||||
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
|
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
|
||||||
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
|
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
|
||||||
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er))
|
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
|
|
||||||
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
|
|
||||||
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
||||||
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
||||||
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
||||||
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
||||||
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
|
|
||||||
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
|
|
||||||
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
|
|
||||||
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
|
|
||||||
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
|
|
||||||
- Web UI loads when the daemon is started from the bundled CLI ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim))
|
|
||||||
- Worktree setup scripts keep your PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908))
|
|
||||||
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
|
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
|
||||||
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
|
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
|
||||||
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
|
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
|
||||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||||
|
|||||||
+24
-4
@@ -4,10 +4,6 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al
|
|||||||
|
|
||||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||||
|
|
||||||
## Store Surface Rules
|
|
||||||
|
|
||||||
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
@@ -41,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Runtime PID Lock
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/paseo.pid`
|
||||||
|
|
||||||
|
The daemon writes this file when it starts and removes it when it exits cleanly.
|
||||||
|
It is a runtime lock, not a durable settings file.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `pid` | `number` | Owner process PID for stop/status operations |
|
||||||
|
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
|
||||||
|
| `hostname` | `string` | Host that created the lock |
|
||||||
|
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
|
||||||
|
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
|
||||||
|
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
|
||||||
|
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
|
||||||
|
|
||||||
|
Desktop-managed is a read-time classification by the desktop app: the daemon
|
||||||
|
executable must be the desktop app executable or live inside the same desktop
|
||||||
|
install. The CLI reports the raw `executablePath` but does not try to classify
|
||||||
|
it because an npm-installed CLI has no authoritative desktop install root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Agent Record
|
## 1. Agent Record
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||||
|
|||||||
@@ -173,19 +173,6 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
|||||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||||
|
|
||||||
### Agent Tool Catalog Measurement
|
|
||||||
|
|
||||||
Measure the MCP `tools/list` payload that Paseo injects into agents with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run measure:agent-tools --workspace=@getpaseo/server
|
|
||||||
```
|
|
||||||
|
|
||||||
The command reports compact JSON bytes, estimated tokens, field totals, largest
|
|
||||||
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
|
|
||||||
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
|
|
||||||
for machine-readable output.
|
|
||||||
|
|
||||||
## paseo.json service scripts
|
## paseo.json service scripts
|
||||||
|
|
||||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||||
|
|||||||
@@ -20,29 +20,6 @@ input focused while its scrollable list lives in a Portal. There is no shared
|
|||||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||||
until then prefer copying the closest file and trimming.
|
until then prefer copying the closest file and trimming.
|
||||||
|
|
||||||
## Popover width contract
|
|
||||||
|
|
||||||
Combobox desktop popovers are never narrower than their trigger, and they grow
|
|
||||||
with content up to a ceiling that is never below the trigger:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
|
||||||
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
|
|
||||||
```
|
|
||||||
|
|
||||||
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
|
|
||||||
trigger still wins when it is wider. Changing this default requires re-verifying
|
|
||||||
every consumer listed here.
|
|
||||||
|
|
||||||
Consumers: `composer/agent-controls/mode-control.tsx`,
|
|
||||||
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
|
|
||||||
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
|
|
||||||
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
|
|
||||||
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
|
|
||||||
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
|
|
||||||
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
|
|
||||||
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
|
|
||||||
|
|
||||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||||
|
|
||||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# Forms
|
|
||||||
|
|
||||||
The paved road for building forms in the app. The schedule form is the golden
|
|
||||||
example; when building or fixing any form, copy its shape, not the shape of
|
|
||||||
whatever screen you happen to be near.
|
|
||||||
|
|
||||||
Golden example files:
|
|
||||||
|
|
||||||
- `packages/app/src/schedules/schedule-form-model.ts` (+ `.test.ts`) — the model
|
|
||||||
- `packages/app/src/schedules/use-schedule-form-model.ts` — model lifetime adapter
|
|
||||||
- `packages/app/src/schedules/use-schedule-form-provider-snapshot.ts` — async input adapter
|
|
||||||
- `packages/app/src/components/schedules/schedule-form-sheet.tsx` — render + intent dispatch
|
|
||||||
- `packages/app/src/schedules/aggregated-schedules.ts` / `hooks/use-schedules.ts` — load-state gating
|
|
||||||
- `packages/app/e2e/schedules-*.spec.ts` — the behavioral contract
|
|
||||||
|
|
||||||
## The form model
|
|
||||||
|
|
||||||
Every non-trivial form gets a **plain TypeScript model** — zero React imports:
|
|
||||||
|
|
||||||
- `openXxxForm(snapshot)` **constructs** a fresh instance from declared inputs
|
|
||||||
(mode, the record being edited, hosts, defaults). Edit mode seeds every value
|
|
||||||
AND display from the snapshot — never from a previous instance.
|
|
||||||
- **Commands** mutate (`setHost`, `setProject(value, display)`, `setModel`, …).
|
|
||||||
Derived state (disclosure, canSubmit, displays) is recomputed inside the
|
|
||||||
model on every publish.
|
|
||||||
- `close()` destroys the instance. `subscribe`/`getState` feed one
|
|
||||||
`useSyncExternalStore` in the component.
|
|
||||||
|
|
||||||
The component renders state and dispatches intent. That is all it does.
|
|
||||||
|
|
||||||
### Lifecycle rules (each one killed a real shipped bug)
|
|
||||||
|
|
||||||
1. **Fresh mount per open.** The sheet returns `null` when not visible and
|
|
||||||
mounts the open form with a `key` derived from mode + record identity.
|
|
||||||
A long-lived component instance shared across create/edit is how edit
|
|
||||||
contaminated create.
|
|
||||||
2. **Construct the model ONCE per mount** — `useState(() => openXxxForm(snapshot))`.
|
|
||||||
NEVER `useMemo(() => open(...), [snapshot])`: the snapshot's identity depends
|
|
||||||
on live data (projects, hosts, preferences), and any background churn — e.g.
|
|
||||||
a scheduled run creating a workspace — would reconstruct the model and wipe
|
|
||||||
the user's in-progress input.
|
|
||||||
3. **Late data is an explicit model input, not a reconstruction.**
|
|
||||||
`applyProviderSnapshot(serverId, …)`, `applyProjectTargets(…)`,
|
|
||||||
`applyHosts(…)`. Adapters pipe identity changes into these with mechanical
|
|
||||||
effects. Input plumbing is fine; orchestration effects are not — the sheet
|
|
||||||
itself has zero `useEffect`/`useRef`, and that is the target for every form.
|
|
||||||
4. **Resolution is explicit model state, per host** (`idle | pending |
|
|
||||||
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
|
|
||||||
state you can render, not an effect race.
|
|
||||||
5. **Displays are owned state.** The selected option's label is captured at
|
|
||||||
selection/seed time (`setProject(value, display)`), never re-derived from a
|
|
||||||
live options list — list churn must not flicker or blank a selection.
|
|
||||||
6. **Disclosure is derived in the model** from user intent
|
|
||||||
(host → project → model → thinking/mode), so fields cannot pop in from
|
|
||||||
cache timing.
|
|
||||||
|
|
||||||
## Form kit
|
|
||||||
|
|
||||||
- Compose `Field` / `SelectField` / `FormTextInput` / `SegmentedControl` /
|
|
||||||
`Switch` from `components/ui/`. Geometry (heights, padding, radii, focus/hover
|
|
||||||
states) is owned by `components/ui/control-geometry.ts` — controls never
|
|
||||||
declare their own, and screens never nudge global component styles to align
|
|
||||||
a row.
|
|
||||||
- The form declares one size for all fields: `sm` on desktop, `md` compact
|
|
||||||
(`useIsCompactFormFactor`).
|
|
||||||
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
|
|
||||||
(isolation on a non-git project — same gating as New Workspace), not rendered
|
|
||||||
disabled with an explanation. Disabled-with-a-reason `hint` is only for
|
|
||||||
transient states the user can resolve.
|
|
||||||
- Copy is opt-in and rare. No hint/subtext unless the maintainer approved the
|
|
||||||
exact string; validation errors are the exception. State a fact (like the
|
|
||||||
timezone) once — never in a preview line AND a helper line.
|
|
||||||
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
|
|
||||||
|
|
||||||
## Data gating
|
|
||||||
|
|
||||||
Aggregate hooks return a discriminated load state:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type AggregateLoadState<T> =
|
|
||||||
| { status: "connecting" } // an answer may still be pending
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; data: T[] };
|
|
||||||
```
|
|
||||||
|
|
||||||
Empty states are only typeable inside `loaded` — a fetch that "succeeded"
|
|
||||||
before hosts connected is `connecting`, not empty. Query keys carry real fetch
|
|
||||||
inputs (host set, connection statuses), never synthetic version counters.
|
|
||||||
|
|
||||||
## Anti-patterns (reject in review on sight)
|
|
||||||
|
|
||||||
- `useEffect` choreography impersonating construct/hydrate/resolve/destroy.
|
|
||||||
- One mounted form instance serving create and edit.
|
|
||||||
- `useMemo`-keyed model construction on live-data identity.
|
|
||||||
- Selected labels derived from live query lists.
|
|
||||||
- `isLoading`/`isEmpty` boolean bags where a load-state union belongs.
|
|
||||||
- Conditional mounting of hint/error rows that shifts layout (subtext renders
|
|
||||||
only when present, but the pattern for that lives in `Field`, not ad hoc).
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-G88S68t7czYE7YtBIuRdkI85+GpVKEZUMZQtcnN73BA=
|
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
|
||||||
|
|||||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -35152,7 +35152,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -36170,12 +36170,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.104",
|
"@getpaseo/client": "0.1.104-beta.4",
|
||||||
"@getpaseo/protocol": "0.1.104",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/server": "0.1.104",
|
"@getpaseo/server": "0.1.104-beta.4",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36421,10 +36421,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.104",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/relay": "0.1.104",
|
"@getpaseo/relay": "0.1.104-beta.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36435,7 +36435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36678,7 +36678,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37574,7 +37574,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -37806,7 +37806,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -37819,7 +37819,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38037,15 +38037,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.1.104",
|
"@getpaseo/client": "0.1.104-beta.4",
|
||||||
"@getpaseo/highlight": "0.1.104",
|
"@getpaseo/highlight": "0.1.104-beta.4",
|
||||||
"@getpaseo/protocol": "0.1.104",
|
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||||
"@getpaseo/relay": "0.1.104",
|
"@getpaseo/relay": "0.1.104-beta.4",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38582,7 +38582,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
|
|||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||||
|
|
||||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
|
||||||
|
|
||||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||||
@@ -59,7 +57,7 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
|||||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
({ daemon, preferences, seedNonce: nonce }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
@@ -75,20 +73,12 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
|||||||
localStorage.setItem("@paseo:e2e", "1");
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||||
|
|
||||||
const rawExtraHosts = localStorage.getItem(extraHostsKey);
|
|
||||||
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
|
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
localStorage.removeItem("@paseo:settings");
|
localStorage.removeItem("@paseo:settings");
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
},
|
},
|
||||||
{
|
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||||
daemon: testDaemon,
|
|
||||||
preferences: createAgentPreferences,
|
|
||||||
seedNonce,
|
|
||||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await provide();
|
await provide();
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { expect, type Locator } from "@playwright/test";
|
|
||||||
|
|
||||||
interface TruncatedLabel {
|
|
||||||
text: string;
|
|
||||||
scrollWidth: number;
|
|
||||||
clientWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectNoTruncation(locator: Locator): Promise<void> {
|
|
||||||
await expect(locator.first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const truncated = await locator.evaluateAll((elements): TruncatedLabel[] => {
|
|
||||||
function isVisible(element: HTMLElement): boolean {
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return (
|
|
||||||
style.display !== "none" &&
|
|
||||||
style.visibility !== "hidden" &&
|
|
||||||
rect.width > 0 &&
|
|
||||||
rect.height > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function directText(element: HTMLElement): string {
|
|
||||||
return Array.from(element.childNodes)
|
|
||||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
||||||
.map((node) => node.textContent ?? "")
|
|
||||||
.join("")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const checked = new Set<HTMLElement>();
|
|
||||||
const failures: TruncatedLabel[] = [];
|
|
||||||
|
|
||||||
for (const root of elements) {
|
|
||||||
if (!(root instanceof HTMLElement) || !isVisible(root)) continue;
|
|
||||||
const candidates = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (checked.has(candidate) || !isVisible(candidate) || candidate.clientWidth <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
checked.add(candidate);
|
|
||||||
|
|
||||||
const text = directText(candidate);
|
|
||||||
if (!text) continue;
|
|
||||||
if (candidate.scrollWidth <= candidate.clientWidth) continue;
|
|
||||||
|
|
||||||
failures.push({
|
|
||||||
text,
|
|
||||||
scrollWidth: candidate.scrollWidth,
|
|
||||||
clientWidth: candidate.clientWidth,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return failures;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(truncated, "option labels should not be horizontally truncated").toEqual([]);
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,10 @@ import { type SeededWorkspace } from "./seed-client";
|
|||||||
|
|
||||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||||
export const FAKE_HOST_MODEL_ID = "fake-host-model";
|
const FAKE_HOST_MODEL_ID = "fake-host-model";
|
||||||
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
const FAKE_HOST_MODEL_LABEL = "Fake host model";
|
||||||
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
type WebSocketMessage = string | Buffer;
|
||||||
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
|
||||||
@@ -20,31 +20,6 @@ export interface FakeScheduleHostWorkspace {
|
|||||||
workspace: Record<string, unknown>;
|
workspace: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FakeScheduleSummary {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
prompt: string;
|
|
||||||
cadence: { type: "cron"; expression: string };
|
|
||||||
target: {
|
|
||||||
type: "new-agent";
|
|
||||||
config: {
|
|
||||||
provider: "mock";
|
|
||||||
cwd: string;
|
|
||||||
model: string;
|
|
||||||
modeId: string;
|
|
||||||
title?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
status: "active";
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
nextRunAt: string | null;
|
|
||||||
lastRunAt: string | null;
|
|
||||||
pausedAt: string | null;
|
|
||||||
expiresAt: string | null;
|
|
||||||
maxRuns: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJson(message: WebSocketMessage): unknown {
|
function parseJson(message: WebSocketMessage): unknown {
|
||||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
try {
|
try {
|
||||||
@@ -139,7 +114,6 @@ export async function installFakeScheduleHost(input: {
|
|||||||
port: string;
|
port: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: Record<string, unknown>;
|
workspace: Record<string, unknown>;
|
||||||
schedules?: FakeScheduleSummary[];
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
@@ -227,7 +201,7 @@ export async function installFakeScheduleHost(input: {
|
|||||||
ws.send(
|
ws.send(
|
||||||
buildSessionMessage("schedule/list/response", {
|
buildSessionMessage("schedule/list/response", {
|
||||||
requestId,
|
requestId,
|
||||||
schedules: input.schedules ?? [],
|
schedules: [],
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -258,24 +232,15 @@ export async function addFakeScheduleHostAndReload(input: {
|
|||||||
}
|
}
|
||||||
const raw = localStorage.getItem(keys.registry);
|
const raw = localStorage.getItem(keys.registry);
|
||||||
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
|
||||||
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
|
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
|
||||||
nextRegistry.push(seededHost);
|
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||||
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
|
|
||||||
|
|
||||||
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
|
|
||||||
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
|
|
||||||
? JSON.parse(rawExtraHosts)
|
|
||||||
: [];
|
|
||||||
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
|
|
||||||
nextExtraHosts.push(seededHost);
|
|
||||||
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
seededHost: host,
|
seededHost: host,
|
||||||
keys: {
|
keys: {
|
||||||
registry: REGISTRY_KEY,
|
registry: REGISTRY_KEY,
|
||||||
nonce: SEED_NONCE_KEY,
|
nonce: SEED_NONCE_KEY,
|
||||||
extraHosts: EXTRA_HOSTS_KEY,
|
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -371,7 +371,6 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
|||||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
// Host group rows are now flat top-level sections (no drill-in).
|
// Host group rows are now flat top-level sections (no drill-in).
|
||||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { expect, type Locator } from "@playwright/test";
|
|
||||||
|
|
||||||
interface SettledOptions {
|
|
||||||
timeout?: number;
|
|
||||||
durationMs?: number;
|
|
||||||
intervalMs?: number;
|
|
||||||
heightTolerance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Sample {
|
|
||||||
text: string;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DURATION_MS = 1_500;
|
|
||||||
const DEFAULT_INTERVAL_MS = 100;
|
|
||||||
const DEFAULT_HEIGHT_TOLERANCE = 1;
|
|
||||||
|
|
||||||
async function sample(locator: Locator): Promise<Sample> {
|
|
||||||
const [text, box] = await Promise.all([locator.textContent(), locator.boundingBox()]);
|
|
||||||
if (!box) {
|
|
||||||
throw new Error("Expected locator to keep a bounding box while sampling settledness.");
|
|
||||||
}
|
|
||||||
return { text: text ?? "", height: box.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectSamples(locator: Locator, options?: SettledOptions): Promise<Sample[]> {
|
|
||||||
await expect(locator).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
|
||||||
|
|
||||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
|
||||||
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
||||||
const samples: Sample[] = [];
|
|
||||||
const deadline = Date.now() + durationMs;
|
|
||||||
|
|
||||||
while (Date.now() <= deadline || samples.length < 2) {
|
|
||||||
samples.push(await sample(locator));
|
|
||||||
await locator.page().waitForTimeout(intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return samples;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertStableHeight(samples: Sample[], tolerance: number): void {
|
|
||||||
const heights = samples.map((entry) => entry.height);
|
|
||||||
const minHeight = Math.min(...heights);
|
|
||||||
const maxHeight = Math.max(...heights);
|
|
||||||
expect(maxHeight - minHeight, `height changed by more than ${tolerance}px`).toBeLessThanOrEqual(
|
|
||||||
tolerance,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSettledText(samples: Sample[]): void {
|
|
||||||
let stableText: string | null = null;
|
|
||||||
let previousText: string | null = null;
|
|
||||||
|
|
||||||
for (const entry of samples) {
|
|
||||||
if (stableText !== null && entry.text !== stableText) {
|
|
||||||
throw new Error(
|
|
||||||
`Text changed after settling. Expected ${JSON.stringify(stableText)}, received ${JSON.stringify(entry.text)}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousText === entry.text) {
|
|
||||||
stableText = entry.text;
|
|
||||||
}
|
|
||||||
previousText = entry.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stableText === null) {
|
|
||||||
throw new Error("Text did not reach a stable value during the settledness window.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectSettled(locator: Locator, options?: SettledOptions): Promise<void> {
|
|
||||||
const samples = await collectSamples(locator, options);
|
|
||||||
assertSettledText(samples);
|
|
||||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectStableHeight(
|
|
||||||
locator: Locator,
|
|
||||||
options?: SettledOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
const samples = await collectSamples(locator, options);
|
|
||||||
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
selectNewWorkspaceProject,
|
selectNewWorkspaceProject,
|
||||||
submitNewWorkspacePrompt,
|
submitNewWorkspacePrompt,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
|
||||||
import { escapeRegex } from "./helpers/regex";
|
import { escapeRegex } from "./helpers/regex";
|
||||||
import { seedWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
@@ -92,10 +91,6 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
|||||||
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
||||||
await modeControl.click();
|
await modeControl.click();
|
||||||
|
|
||||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectNoTruncation(popup);
|
|
||||||
|
|
||||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
await searchInput.fill(label);
|
await searchInput.fill(label);
|
||||||
@@ -110,19 +105,6 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
|||||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectThinkingOptionsFit(page: Page): Promise<void> {
|
|
||||||
const thinkingControl = page.getByTestId("agent-thinking-selector").first();
|
|
||||||
await expect(thinkingControl).toBeVisible({ timeout: 30_000 });
|
|
||||||
await thinkingControl.click();
|
|
||||||
|
|
||||||
const popup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectNoTruncation(popup);
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
||||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||||
}> {
|
}> {
|
||||||
@@ -176,7 +158,6 @@ test.describe("New workspace Codex mode preferences", () => {
|
|||||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expectThinkingOptionsFit(page);
|
|
||||||
await selectMode(page, "Full access");
|
await selectMode(page, "Full access");
|
||||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
openStartingRefPicker,
|
openStartingRefPicker,
|
||||||
selectBranchInPicker,
|
selectBranchInPicker,
|
||||||
|
selectWorkspaceIsolation,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
@@ -63,11 +63,7 @@ test.describe("New workspace isolation memory", () => {
|
|||||||
projectDisplayName: openedProject.projectDisplayName,
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectWorkspaceIsolationSelected(page, "local");
|
await expectWorkspaceIsolationSelected(page, "local");
|
||||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
await selectWorkspaceIsolation(page, "worktree");
|
||||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expectNoTruncation(isolationPopup);
|
|
||||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
|
||||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||||
|
|
||||||
await openStartingRefPicker(page);
|
await openStartingRefPicker(page);
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
|
||||||
import {
|
|
||||||
addFakeScheduleHostAndReload,
|
|
||||||
buildFakeScheduleHostWorkspace,
|
|
||||||
FAKE_HOST_MODEL_ID,
|
|
||||||
FAKE_HOST_MODEL_LABEL,
|
|
||||||
installFakeScheduleHost,
|
|
||||||
} from "./helpers/schedule-fake-host";
|
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
|
||||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||||
|
|
||||||
interface ScheduleSeedClient {
|
interface ScheduleSeedClient {
|
||||||
@@ -66,42 +56,6 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
|
|||||||
.catch(ignoreScheduleDeleteError);
|
.catch(ignoreScheduleDeleteError);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FakeScheduleHostSchedule = NonNullable<
|
|
||||||
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
|
|
||||||
>[number];
|
|
||||||
|
|
||||||
function buildFakeHostSchedule(input: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
cwd: string;
|
|
||||||
}): FakeScheduleHostSchedule {
|
|
||||||
const now = "2026-07-01T00:00:00.000Z";
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
name: input.name,
|
|
||||||
prompt: "Run on the secondary host.",
|
|
||||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
|
||||||
target: {
|
|
||||||
type: "new-agent",
|
|
||||||
config: {
|
|
||||||
provider: "mock",
|
|
||||||
cwd: input.cwd,
|
|
||||||
model: FAKE_HOST_MODEL_ID,
|
|
||||||
modeId: "load-test",
|
|
||||||
title: input.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
nextRunAt: now,
|
|
||||||
lastRunAt: null,
|
|
||||||
pausedAt: null,
|
|
||||||
expiresAt: null,
|
|
||||||
maxRuns: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Schedules", () => {
|
test.describe("Schedules", () => {
|
||||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
@@ -125,114 +79,14 @@ test.describe("Schedules", () => {
|
|||||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||||
|
|
||||||
await row.click();
|
await row.click();
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||||
await expectStableHeight(formSheet);
|
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
|
||||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
workspace.projectDisplayName,
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
{ timeout: 30_000 },
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
);
|
||||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
|
||||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
timeout: 30_000,
|
||||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(hostTrigger).toBeDisabled();
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expectSettled(modeTrigger);
|
|
||||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
|
||||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
|
||||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
|
||||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
|
||||||
const scheduleId = "fake-host-schedule";
|
|
||||||
const scheduleName = "Secondary host schedule";
|
|
||||||
|
|
||||||
await installFakeScheduleHost({
|
|
||||||
page,
|
|
||||||
port: fakePort,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
workspace: fakeHost.workspace,
|
|
||||||
schedules: [
|
|
||||||
buildFakeHostSchedule({
|
|
||||||
id: scheduleId,
|
|
||||||
name: scheduleName,
|
|
||||||
cwd: String(fakeHost.workspace.workspaceDirectory),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
await addFakeScheduleHostAndReload({
|
|
||||||
page,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
label: "Fake host",
|
|
||||||
port: fakePort,
|
|
||||||
});
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
|
||||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
|
||||||
|
|
||||||
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
|
|
||||||
await expect(hostTrigger).toBeDisabled();
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
|
|
||||||
await expectSettled(modeTrigger);
|
|
||||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("create opens pristine after closing an edit form", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const scheduleName = `Pristine create ${Date.now()}`;
|
|
||||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
|
||||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
|
||||||
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
await page.getByRole("button", { name: "Cancel" }).click();
|
|
||||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
|
|
||||||
await page.getByTestId("schedules-new").click();
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
await expect(projectTrigger).toContainText(/select project/i);
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,27 +6,13 @@ import {
|
|||||||
installFakeScheduleHost,
|
installFakeScheduleHost,
|
||||||
} from "./helpers/schedule-fake-host";
|
} from "./helpers/schedule-fake-host";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
|
||||||
import { escapeRegex } from "./helpers/regex";
|
|
||||||
import { expectNoTruncation } from "./helpers/no-truncation";
|
|
||||||
import { expectSettled, expectStableHeight } from "./helpers/settled";
|
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||||
|
|
||||||
const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 };
|
|
||||||
|
|
||||||
interface ScheduleListItem {
|
interface ScheduleListItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
cadence?: { type: "cron"; expression: string };
|
target: { type: string; config?: { cwd?: string } };
|
||||||
target: {
|
|
||||||
type: string;
|
|
||||||
config?: {
|
|
||||||
cwd?: string;
|
|
||||||
archiveOnFinish?: boolean;
|
|
||||||
isolation?: "local" | "worktree";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleSeedClient {
|
interface ScheduleSeedClient {
|
||||||
@@ -38,12 +24,7 @@ async function selectModelByLabel(page: Page, label: string): Promise<void> {
|
|||||||
await page.getByRole("button", { name: /select model/i }).click();
|
await page.getByRole("button", { name: /select model/i }).click();
|
||||||
const popup = page.getByTestId("combobox-desktop-container");
|
const popup = page.getByTestId("combobox-desktop-container");
|
||||||
await expect(popup).toBeVisible({ timeout: 30_000 });
|
await expect(popup).toBeVisible({ timeout: 30_000 });
|
||||||
const searchInput = page.getByTestId("model-search-input").first();
|
await popup.getByText(label, { exact: true }).click();
|
||||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
|
||||||
await searchInput.fill(label);
|
|
||||||
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
|
||||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
|
||||||
await option.click();
|
|
||||||
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
await expect(popup).toHaveCount(0, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +40,6 @@ async function deleteScheduleByName(workspace: SeededWorkspace, name: string): P
|
|||||||
async function expectScheduleCreatedForProject(input: {
|
async function expectScheduleCreatedForProject(input: {
|
||||||
workspace: SeededWorkspace;
|
workspace: SeededWorkspace;
|
||||||
name: string;
|
name: string;
|
||||||
cadenceExpression: string;
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
||||||
const list = await client.scheduleList();
|
const list = await client.scheduleList();
|
||||||
@@ -67,10 +47,6 @@ async function expectScheduleCreatedForProject(input: {
|
|||||||
expect(schedule).toEqual(
|
expect(schedule).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
cadence: expect.objectContaining({
|
|
||||||
type: "cron",
|
|
||||||
expression: input.cadenceExpression,
|
|
||||||
}),
|
|
||||||
target: expect.objectContaining({
|
target: expect.objectContaining({
|
||||||
type: "new-agent",
|
type: "new-agent",
|
||||||
config: expect.objectContaining({
|
config: expect.objectContaining({
|
||||||
@@ -81,69 +57,6 @@ async function expectScheduleCreatedForProject(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectScheduleKnobs(input: {
|
|
||||||
workspace: SeededWorkspace;
|
|
||||||
name: string;
|
|
||||||
archiveOnFinish: boolean;
|
|
||||||
isolation: "local" | "worktree";
|
|
||||||
}): Promise<void> {
|
|
||||||
const client = input.workspace.client as unknown as ScheduleSeedClient;
|
|
||||||
const list = await client.scheduleList();
|
|
||||||
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
|
|
||||||
expect(schedule).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: input.name,
|
|
||||||
target: expect.objectContaining({
|
|
||||||
type: "new-agent",
|
|
||||||
config: expect.objectContaining({
|
|
||||||
archiveOnFinish: input.archiveOnFinish,
|
|
||||||
isolation: input.isolation,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openNewScheduleSheet(page: Page): Promise<void> {
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bottomSheetBackdrop(page: Page) {
|
|
||||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dismissScheduleSheetWithBackdrop(page: Page): Promise<void> {
|
|
||||||
const backdrop = bottomSheetBackdrop(page);
|
|
||||||
await expect(backdrop).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(async () => {
|
|
||||||
const box = await backdrop.boundingBox();
|
|
||||||
if (box) {
|
|
||||||
await page.mouse.click(box.x + box.width / 2, box.y + 24);
|
|
||||||
}
|
|
||||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
|
||||||
}).toPass({ timeout: 15_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectScheduleSheetClosedAndStable(page: Page): Promise<void> {
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await expect(formSheet).toHaveCount(0, { timeout: 1_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findScheduleIdByName(workspace: SeededWorkspace, name: string): Promise<string> {
|
|
||||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
|
||||||
const list = await client.scheduleList();
|
|
||||||
const schedule = list.schedules.find((candidate) => candidate.name === name);
|
|
||||||
if (!schedule) {
|
|
||||||
throw new Error(`Expected schedule named ${name} to exist`);
|
|
||||||
}
|
|
||||||
return schedule.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Schedules project target", () => {
|
test.describe("Schedules project target", () => {
|
||||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
@@ -154,30 +67,10 @@ test.describe("Schedules project target", () => {
|
|||||||
cleanupTasks.length = 0;
|
cleanupTasks.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dismisses the new schedule sheet without reopening", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-sheet-dismiss-", git: false });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
await page.setViewportSize(MOBILE_SHEET_VIEWPORT);
|
|
||||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await openNewScheduleSheet(page);
|
|
||||||
await dismissScheduleSheetWithBackdrop(page);
|
|
||||||
await expectScheduleSheetClosedAndStable(page);
|
|
||||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await openNewScheduleSheet(page);
|
|
||||||
await page.getByRole("button", { name: "Cancel" }).click();
|
|
||||||
await expectScheduleSheetClosedAndStable(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-", git: false });
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
cleanupTasks.push(() => workspace.cleanup());
|
||||||
const scheduleName = `Project schedule ${Date.now()}`;
|
const scheduleName = `Project schedule ${Date.now()}`;
|
||||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
||||||
@@ -191,84 +84,29 @@ test.describe("Schedules project target", () => {
|
|||||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
await expect(page.getByTestId("schedule-host-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-project-trigger")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
|
||||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
|
||||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
workspace.projectDisplayName,
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
|
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
|
||||||
await expect(modelTrigger).toContainText("Ten second stream");
|
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
|
||||||
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
|
||||||
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
|
||||||
await expect(page.getByText("Worktree isolation is available for git projects.")).toHaveCount(
|
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
|
||||||
await page.getByTestId("schedule-cadence-preset-daily-9").click();
|
|
||||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
|
||||||
await expect(page.getByTestId("cadence-cron-expression")).toHaveValue("0 9 * * *");
|
|
||||||
|
|
||||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
await page.getByLabel("Schedule name").fill(scheduleName);
|
||||||
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
await page.getByLabel("Prompt").fill("Summarize the project status.");
|
||||||
|
await page.getByRole("button", { name: "Cron" }).click();
|
||||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||||
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
||||||
await expectScheduleCreatedForProject({
|
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
|
||||||
workspace,
|
|
||||||
name: scheduleName,
|
|
||||||
cadenceExpression: "0 9 * * *",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
|
||||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
|
||||||
await installFakeScheduleHost({
|
|
||||||
page,
|
|
||||||
port: fakePort,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
workspace: fakeHost.workspace,
|
|
||||||
});
|
|
||||||
await addFakeScheduleHostAndReload({
|
|
||||||
page,
|
|
||||||
serverId: fakeHost.serverId,
|
|
||||||
label: "Fake host",
|
|
||||||
port: fakePort,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
|
|
||||||
await expect(hostFilterTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await hostFilterTrigger.click();
|
|
||||||
const hostFilterPopup = page.getByTestId("combobox-desktop-container").last();
|
|
||||||
await expect(hostFilterPopup).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expectNoTruncation(hostFilterPopup);
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears the selected project and model when the host changes", async ({ page }) => {
|
test("clears the selected model when the chosen project moves to another host", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
cleanupTasks.push(() => workspace.cleanup());
|
||||||
const serverId = getServerId();
|
|
||||||
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
|
||||||
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
|
||||||
|
|
||||||
@@ -290,126 +128,26 @@ test.describe("Schedules project target", () => {
|
|||||||
});
|
});
|
||||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
|
|
||||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
|
||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
|
||||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
|
||||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
|
||||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(projectTrigger).toHaveCount(0);
|
|
||||||
await expect(modelTrigger).toHaveCount(0);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
await hostTrigger.click();
|
|
||||||
await page.getByTestId(`schedule-host-option-${serverId}`).click();
|
|
||||||
await expect(projectTrigger).toBeVisible();
|
|
||||||
await expect(modelTrigger).toHaveCount(0);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
|
|
||||||
await projectTrigger.click();
|
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||||
await expectSettled(projectTrigger);
|
workspace.projectDisplayName,
|
||||||
|
);
|
||||||
|
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
await selectModelByLabel(page, "Ten second stream");
|
||||||
await expect(modelTrigger).toContainText("Ten second stream");
|
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
|
||||||
await expectSettled(modelTrigger);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toBeVisible();
|
|
||||||
await expectSettled(modeTrigger);
|
|
||||||
|
|
||||||
await hostTrigger.click();
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
|
|
||||||
await expect(hostTrigger).toContainText("Fake host");
|
|
||||||
await expect(projectTrigger).toContainText(/select project/i);
|
|
||||||
await expect(modelTrigger).toHaveCount(0);
|
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
await expectSettled(hostTrigger);
|
|
||||||
await expectSettled(projectTrigger);
|
|
||||||
|
|
||||||
await projectTrigger.click();
|
|
||||||
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
|
||||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
||||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
|
||||||
await expectSettled(projectTrigger);
|
fakeHost.projectDisplayName,
|
||||||
await expect(modelTrigger).toContainText(/select model/i);
|
);
|
||||||
await expectSettled(modelTrigger);
|
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
|
||||||
await expect(modeTrigger).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
|
||||||
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
await page.getByLabel("Prompt").fill("Run on the fake host project.");
|
||||||
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates and edits schedule isolation and archive cleanup knobs", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-knobs-" });
|
|
||||||
cleanupTasks.push(() => workspace.cleanup());
|
|
||||||
const scheduleName = `Knob schedule ${Date.now()}`;
|
|
||||||
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await page.goto(buildSchedulesRoute());
|
|
||||||
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await page.getByTestId("schedules-empty-new").click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
|
||||||
await expect(
|
|
||||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await page.getByTestId("schedule-isolation-trigger").click();
|
|
||||||
await page.getByTestId("schedule-isolation-worktree").click();
|
|
||||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
|
||||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
|
||||||
await page.getByLabel("Schedule name").fill(scheduleName);
|
|
||||||
await page.getByLabel("Prompt").fill("Run with custom workspace cleanup.");
|
|
||||||
await page.getByTestId("schedule-cadence-preset-trigger").click();
|
|
||||||
await page.getByTestId("schedule-cadence-preset-every-hour").click();
|
|
||||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await expectScheduleKnobs({
|
|
||||||
workspace,
|
|
||||||
name: scheduleName,
|
|
||||||
archiveOnFinish: false,
|
|
||||||
isolation: "worktree",
|
|
||||||
});
|
|
||||||
|
|
||||||
const scheduleId = await findScheduleIdByName(workspace, scheduleName);
|
|
||||||
await page.getByTestId(`schedule-row-${scheduleId}`).click();
|
|
||||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
|
||||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expectStableHeight(formSheet);
|
|
||||||
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
|
|
||||||
await expect(page.getByTestId("schedule-archive-on-finish-switch")).toHaveAttribute(
|
|
||||||
"aria-checked",
|
|
||||||
"false",
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
|
||||||
).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("schedule-isolation-trigger").click();
|
|
||||||
await page.getByTestId("schedule-isolation-local").click();
|
|
||||||
await page.getByTestId("schedule-archive-on-finish-switch").click();
|
|
||||||
await page.getByRole("button", { name: "Save changes" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await expectScheduleKnobs({
|
|
||||||
workspace,
|
|
||||||
name: scheduleName,
|
|
||||||
archiveOnFinish: true,
|
|
||||||
isolation: "local",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
|
|||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await openHostSection(page, serverId, "host");
|
await openHostSection(page, serverId, "host");
|
||||||
await expectSettingsHeader(page, "Overview");
|
await expectSettingsHeader(page, "Host");
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await expectHostActionCards(page, serverId);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.104",
|
"version": "0.1.104-beta.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
|||||||
import { I18nProvider } from "@/i18n/provider";
|
import { I18nProvider } from "@/i18n/provider";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
hasConfiguredLocalDaemonOverride,
|
||||||
|
|||||||
@@ -1,38 +1,7 @@
|
|||||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||||
import {
|
|
||||||
buildSettingsHostSectionRoute,
|
|
||||||
buildSettingsRoute,
|
|
||||||
isSettingsSectionSlug,
|
|
||||||
type SettingsSectionSlug,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
|
||||||
function SettingsDaemonRedirect() {
|
|
||||||
const hosts = useHosts();
|
|
||||||
const localDaemon = useLocalDaemonServerIdState();
|
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
|
||||||
|
|
||||||
if (localDaemon.status === "loading") {
|
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
localDaemon.status === "resolved" &&
|
|
||||||
localDaemon.serverId !== null &&
|
|
||||||
hosts.some((host) => host.serverId === localDaemon.serverId)
|
|
||||||
) {
|
|
||||||
return <Redirect href={buildSettingsHostSectionRoute(localDaemon.serverId, "host")} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Redirect href={buildSettingsRoute()} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsSectionRoute() {
|
export default function SettingsSectionRoute() {
|
||||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||||
@@ -41,14 +10,5 @@ export default function SettingsSectionRoute() {
|
|||||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||||
|
|
||||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
|
||||||
if (rawSection === "daemon") {
|
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<SettingsDaemonRedirect />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||||
import { createControlGeometry } from "@/components/ui/control-geometry";
|
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -221,7 +220,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
adaptiveInputOutline: {
|
adaptiveInputOutline: {
|
||||||
...createControlGeometry(theme).controlFocusRingColor,
|
outlineColor: theme.colors.accent,
|
||||||
},
|
},
|
||||||
adaptiveInputText: {
|
adaptiveInputText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -232,7 +231,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||||
const WEB_EXIT_DURATION_MS = 160;
|
|
||||||
|
|
||||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -450,7 +448,6 @@ export interface AdaptiveModalSheetProps {
|
|||||||
header: SheetHeader;
|
header: SheetHeader;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDismiss?: () => void;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Sticky footer rendered below the scrollable content. */
|
/** Sticky footer rendered below the scrollable content. */
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
@@ -471,7 +468,6 @@ export function AdaptiveModalSheet({
|
|||||||
header,
|
header,
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
onDismiss,
|
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
@@ -537,20 +533,6 @@ export function AdaptiveModalSheet({
|
|||||||
isEnabled: isMobile,
|
isEnabled: isMobile,
|
||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
|
||||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
|
||||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
handleSheetDismiss();
|
|
||||||
onDismiss?.();
|
|
||||||
}, [handleSheetDismiss, onDismiss]);
|
|
||||||
const notifyNativeModalDismiss = useCallback(() => {
|
|
||||||
if (nativeModalDismissNotifiedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nativeModalDismissNotifiedRef.current = true;
|
|
||||||
onDismiss?.();
|
|
||||||
}, [onDismiss]);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -563,53 +545,12 @@ export function AdaptiveModalSheet({
|
|||||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||||
[desktopMaxWidth],
|
[desktopMaxWidth],
|
||||||
);
|
);
|
||||||
const desktopOverlayStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.desktopOverlay,
|
|
||||||
isWeb && {
|
|
||||||
opacity: isWebClosing ? 0 : 1,
|
|
||||||
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
|
|
||||||
transitionProperty: "opacity",
|
|
||||||
transitionTimingFunction: "ease",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[isWebClosing],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || isMobile || !visible) return;
|
if (!isWeb || isMobile || !visible) return;
|
||||||
return pushEscHandler(onClose);
|
return pushEscHandler(onClose);
|
||||||
}, [visible, isMobile, onClose]);
|
}, [visible, isMobile, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
nativeModalDismissNotifiedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWeb || isMobile) return;
|
|
||||||
if (visible) {
|
|
||||||
setShouldRenderWeb(true);
|
|
||||||
setIsWebClosing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!shouldRenderWeb) return;
|
|
||||||
setIsWebClosing(true);
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
setShouldRenderWeb(false);
|
|
||||||
setIsWebClosing(false);
|
|
||||||
onDismiss?.();
|
|
||||||
}, WEB_EXIT_DURATION_MS);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
|
||||||
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<IsolatedBottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
@@ -618,7 +559,7 @@ export function AdaptiveModalSheet({
|
|||||||
index={0}
|
index={0}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
onDismiss={handleDismiss}
|
onDismiss={handleSheetDismiss}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
@@ -673,7 +614,7 @@ export function AdaptiveModalSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const desktopContent = (
|
const desktopContent = (
|
||||||
<View style={desktopOverlayStyle} testID={testID}>
|
<View style={styles.desktopOverlay} testID={testID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityLabel={t("common.actions.dismiss")}
|
accessibilityLabel={t("common.actions.dismiss")}
|
||||||
style={ABSOLUTE_FILL_STYLE}
|
style={ABSOLUTE_FILL_STYLE}
|
||||||
@@ -685,7 +626,7 @@ export function AdaptiveModalSheet({
|
|||||||
|
|
||||||
// On web, use portal to overlay root for consistent stacking with toasts
|
// On web, use portal to overlay root for consistent stacking with toasts
|
||||||
if (isWeb && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
if (!shouldRenderWeb) return null;
|
if (!visible) return null;
|
||||||
return createPortal(desktopContent, getOverlayRoot());
|
return createPortal(desktopContent, getOverlayRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +636,6 @@ export function AdaptiveModalSheet({
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
onDismiss={notifyNativeModalDismiss}
|
|
||||||
hardwareAccelerated
|
hardwareAccelerated
|
||||||
>
|
>
|
||||||
{desktopContent}
|
{desktopContent}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
import {
|
import {
|
||||||
formatAppDiagnosticHeader,
|
formatAppDiagnosticHeader,
|
||||||
formatDiagnosticSection,
|
formatDiagnosticSection,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
formatServerInfoSection,
|
formatServerInfoSection,
|
||||||
redactAppDiagnosticReport,
|
redactAppDiagnosticReport,
|
||||||
} from "@/diagnostics/app-diagnostic-report";
|
} from "@/diagnostics/app-diagnostic-report";
|
||||||
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
|
|
||||||
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
@@ -261,6 +261,35 @@ export function AppDiagnosticSheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
|
||||||
|
try {
|
||||||
|
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||||
|
return {
|
||||||
|
status: "done",
|
||||||
|
sections: [
|
||||||
|
formatDiagnosticSection("Desktop", [
|
||||||
|
{ label: "Daemon status", value: status.status },
|
||||||
|
{ label: "Desktop managed", value: String(status.desktopManaged) },
|
||||||
|
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
|
||||||
|
{ label: "Daemon version", value: status.version ?? "unknown" },
|
||||||
|
{ label: "Daemon home", value: status.home || "unknown" },
|
||||||
|
{ label: "Log path", value: logs.logPath || "unknown" },
|
||||||
|
{ label: "Error", value: status.error ?? "none" },
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
"Desktop daemon log tail",
|
||||||
|
logs.contents ? indentBlock(logs.contents) : " No log lines found",
|
||||||
|
].join("\n"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function collectHostDiagnosticSections(
|
async function collectHostDiagnosticSections(
|
||||||
host: HostProfile,
|
host: HostProfile,
|
||||||
snapshot: HostRuntimeSnapshot | null,
|
snapshot: HostRuntimeSnapshot | null,
|
||||||
@@ -309,6 +338,14 @@ async function collectHostDiagnosticSections(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function indentBlock(value: string): string {
|
||||||
|
return value
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => ` ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function toMessage(error: unknown): string {
|
function toMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,39 +4,24 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
||||||
import {
|
|
||||||
Combobox,
|
|
||||||
ComboboxItem,
|
|
||||||
type ComboboxOption,
|
|
||||||
type ComboboxProps,
|
|
||||||
} from "@/components/ui/combobox";
|
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
|
||||||
import {
|
|
||||||
buildSelectedTriggerLabel,
|
|
||||||
filterAndRankModelRows,
|
|
||||||
getAllProviderModelRows,
|
|
||||||
getProviderModelRows,
|
|
||||||
resolveSelectedModelLabel,
|
|
||||||
type ProviderSelectionModelRow,
|
|
||||||
type ProviderSelectorProvider,
|
|
||||||
} from "@/provider-selection/provider-selection";
|
|
||||||
|
|
||||||
const IS_WEB = platformIsWeb;
|
const IS_WEB = platformIsWeb;
|
||||||
|
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
|
||||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
@@ -62,67 +47,22 @@ function drillDownRowStyle({
|
|||||||
pressed && styles.drillDownRowPressed,
|
pressed && styles.drillDownRowPressed,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import {
|
||||||
|
buildSelectedTriggerLabel,
|
||||||
|
filterAndRankModelRows,
|
||||||
|
getAllProviderModelRows,
|
||||||
|
getProviderModelRows,
|
||||||
|
resolveSelectedModelLabel,
|
||||||
|
type ProviderSelectionModelRow,
|
||||||
|
type ProviderSelectorProvider,
|
||||||
|
} from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
||||||
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
||||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||||
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
||||||
|
|
||||||
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
|
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
||||||
const ThemedSearch = withUnistyles(Search);
|
|
||||||
const ThemedSettings = withUnistyles(Settings);
|
|
||||||
const ThemedStar = withUnistyles(Star);
|
|
||||||
|
|
||||||
const foregroundMutedMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
});
|
|
||||||
|
|
||||||
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
|
|
||||||
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
|
|
||||||
});
|
|
||||||
|
|
||||||
const favoriteStarMapping =
|
|
||||||
(isFavorite: boolean, hovered: boolean) =>
|
|
||||||
(theme: Theme): { color: string; fill: string } => {
|
|
||||||
const favoriteColor = theme.colors.palette.amber[500];
|
|
||||||
if (isFavorite) {
|
|
||||||
return { color: favoriteColor, fill: favoriteColor };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
|
|
||||||
fill: "transparent",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProviderGlyphTone = "muted" | "foreground";
|
|
||||||
|
|
||||||
function ProviderGlyph({
|
|
||||||
provider,
|
|
||||||
size,
|
|
||||||
tone = "muted",
|
|
||||||
}: {
|
|
||||||
provider: string;
|
|
||||||
size: number;
|
|
||||||
tone?: ProviderGlyphTone;
|
|
||||||
}) {
|
|
||||||
const Icon = getProviderIcon(provider);
|
|
||||||
const color =
|
|
||||||
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
|
||||||
return <Icon size={size} color={color} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
|
|
||||||
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
|
|
||||||
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
|
|
||||||
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
|
|
||||||
return <ThemedStar size={ICON_SIZE.md} uniProps={uniProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectorView =
|
type SelectorView =
|
||||||
| { kind: "all" }
|
| { kind: "all" }
|
||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
@@ -149,8 +89,6 @@ interface CombinedModelSelectorProps {
|
|||||||
isRetryingProvider?: boolean;
|
isRetryingProvider?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
|
||||||
desktopMinWidth?: number;
|
|
||||||
/**
|
/**
|
||||||
* Render the custom trigger as a full-width form field: the outer Pressable
|
* Render the custom trigger as a full-width form field: the outer Pressable
|
||||||
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
||||||
@@ -211,7 +149,9 @@ function ModelRow({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const ProviderIcon = getProviderIcon(row.provider);
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
@@ -222,8 +162,8 @@ function ModelRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const leadingSlot = useMemo(
|
const leadingSlot = useMemo(
|
||||||
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
[row.provider],
|
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
const trailingSlot = useMemo(
|
const trailingSlot = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -238,10 +178,32 @@ function ModelRow({
|
|||||||
}
|
}
|
||||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||||
>
|
>
|
||||||
{({ hovered }) => <FavoriteStar isFavorite={isFavorite} hovered={Boolean(hovered)} />}
|
{({ hovered }) => {
|
||||||
|
let starColor: string;
|
||||||
|
if (isFavorite) starColor = theme.colors.palette.amber[500];
|
||||||
|
else if (hovered) starColor = theme.colors.foregroundMuted;
|
||||||
|
else starColor = theme.colors.border;
|
||||||
|
return (
|
||||||
|
<Star
|
||||||
|
size={16}
|
||||||
|
color={starColor}
|
||||||
|
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null,
|
) : null,
|
||||||
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
|
[
|
||||||
|
onToggleFavorite,
|
||||||
|
handleToggleFavorite,
|
||||||
|
isFavorite,
|
||||||
|
row.provider,
|
||||||
|
row.modelId,
|
||||||
|
theme.colors.palette.amber,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
theme.colors.border,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -343,7 +305,9 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const ProvIcon = getProviderIcon(provider.id);
|
||||||
const selection = provider.modelSelection;
|
const selection = provider.modelSelection;
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
@@ -363,16 +327,18 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
|||||||
} else if (selection.kind === "loading") {
|
} else if (selection.kind === "loading") {
|
||||||
stateNode = (
|
stateNode = (
|
||||||
<View style={styles.rowStateInline}>
|
<View style={styles.rowStateInline}>
|
||||||
<View style={styles.rowSpinner}>
|
<ActivityIndicator
|
||||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
size="small"
|
||||||
</View>
|
color={theme.colors.foregroundMuted}
|
||||||
|
style={styles.rowSpinner}
|
||||||
|
/>
|
||||||
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
stateNode = (
|
stateNode = (
|
||||||
<View style={styles.rowStateInline}>
|
<View style={styles.rowStateInline}>
|
||||||
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -380,11 +346,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{provider.label}</Text>
|
<Text style={styles.drillDownText}>{provider.label}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
{stateNode}
|
{stateNode}
|
||||||
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -480,13 +446,14 @@ function ProviderErrorEmptyState({
|
|||||||
onRetryProvider?: (provider: AgentProvider) => void;
|
onRetryProvider?: (provider: AgentProvider) => void;
|
||||||
isRetryingProvider: boolean;
|
isRetryingProvider: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleRetry = useCallback(() => {
|
const handleRetry = useCallback(() => {
|
||||||
onRetryProvider?.(providerId);
|
onRetryProvider?.(providerId);
|
||||||
}, [onRetryProvider, providerId]);
|
}, [onRetryProvider, providerId]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.emptyStateText}>{message}</Text>
|
<Text style={styles.emptyStateText}>{message}</Text>
|
||||||
{onRetryProvider ? (
|
{onRetryProvider ? (
|
||||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||||
@@ -510,6 +477,7 @@ function SelectorContent({
|
|||||||
onRetryProvider,
|
onRetryProvider,
|
||||||
isRetryingProvider,
|
isRetryingProvider,
|
||||||
}: SelectorContentProps) {
|
}: SelectorContentProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||||
const selectedViewProvider = useMemo(
|
const selectedViewProvider = useMemo(
|
||||||
@@ -533,7 +501,7 @@ function SelectorContent({
|
|||||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||||
const emptyState = (
|
const emptyState = (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<ThemedSearch size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -546,9 +514,11 @@ function SelectorContent({
|
|||||||
if (drillSelection.kind === "loading") {
|
if (drillSelection.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<View style={styles.rowSpinner}>
|
<ActivityIndicator
|
||||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
size="small"
|
||||||
</View>
|
color={theme.colors.foregroundMuted}
|
||||||
|
style={styles.rowSpinner}
|
||||||
|
/>
|
||||||
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -615,10 +585,9 @@ export function CombinedModelSelector({
|
|||||||
isRetryingProvider = false,
|
isRetryingProvider = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
serverId = null,
|
serverId = null,
|
||||||
desktopPlacement,
|
|
||||||
desktopMinWidth,
|
|
||||||
triggerFill = false,
|
triggerFill = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -674,6 +643,7 @@ export function CombinedModelSelector({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
return resolveSelectedModelLabel({
|
return resolveSelectedModelLabel({
|
||||||
@@ -780,6 +750,7 @@ export function CombinedModelSelector({
|
|||||||
if (view.kind === "all") {
|
if (view.kind === "all") {
|
||||||
return { title: t("modelSelector.title") };
|
return { title: t("modelSelector.title") };
|
||||||
}
|
}
|
||||||
|
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={openProviderSettings}
|
onPress={openProviderSettings}
|
||||||
@@ -792,12 +763,17 @@ export function CombinedModelSelector({
|
|||||||
})}
|
})}
|
||||||
testID={`selector-header-settings-${view.providerId}`}
|
testID={`selector-header-settings-${view.providerId}`}
|
||||||
>
|
>
|
||||||
<HeaderSettingsIcon disabled={!serverId} />
|
<Settings
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
title: view.providerLabel,
|
title: view.providerLabel,
|
||||||
leading: <ProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />,
|
leading: ProviderIconForView ? (
|
||||||
|
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||||
|
) : undefined,
|
||||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||||
actions: headerActions,
|
actions: headerActions,
|
||||||
search: {
|
search: {
|
||||||
@@ -813,10 +789,15 @@ export function CombinedModelSelector({
|
|||||||
singleProviderView,
|
singleProviderView,
|
||||||
serverId,
|
serverId,
|
||||||
openProviderSettings,
|
openProviderSettings,
|
||||||
|
theme.colors.border,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
handleBackToAll,
|
handleBackToAll,
|
||||||
handleSearchQueryChange,
|
handleSearchQueryChange,
|
||||||
searchResetKey,
|
searchResetKey,
|
||||||
t,
|
t,
|
||||||
|
theme.iconSize.md,
|
||||||
|
theme.iconSize.sm,
|
||||||
|
theme.colors.foreground,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -854,8 +835,8 @@ export function CombinedModelSelector({
|
|||||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
{hasSelectedProvider ? (
|
{ProviderIcon ? (
|
||||||
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
) : null}
|
) : null}
|
||||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
@@ -869,8 +850,8 @@ export function CombinedModelSelector({
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement={desktopPlacement}
|
desktopPlacement="top-start"
|
||||||
desktopMinWidth={desktopMinWidth}
|
desktopMinWidth={360}
|
||||||
desktopFixedHeight={desktopFixedHeight}
|
desktopFixedHeight={desktopFixedHeight}
|
||||||
header={sheetHeader}
|
header={sheetHeader}
|
||||||
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
||||||
@@ -891,7 +872,7 @@ export function CombinedModelSelector({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.sheetLoadingState}>
|
<View style={styles.sheetLoadingState}>
|
||||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1063,10 +1044,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
providerIconMuted: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
providerIconForeground: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export const ADD_HOST_OPTION_ID = "__add_host__";
|
export const ADD_HOST_OPTION_ID = "__add_host__";
|
||||||
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
||||||
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
|
|
||||||
|
|
||||||
export function getHostPickerLabel(
|
export function getHostPickerLabel(
|
||||||
hosts: Array<{ label: string; serverId: string }>,
|
hosts: Array<{ label: string; serverId: string }>,
|
||||||
|
|||||||
@@ -11,16 +11,10 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
|
|||||||
import {
|
import {
|
||||||
ADD_HOST_OPTION_ID,
|
ADD_HOST_OPTION_ID,
|
||||||
ALL_HOSTS_OPTION_ID,
|
ALL_HOSTS_OPTION_ID,
|
||||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
|
||||||
getHostPickerLabel,
|
getHostPickerLabel,
|
||||||
} from "./host-picker-constants";
|
} from "./host-picker-constants";
|
||||||
|
|
||||||
export {
|
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
|
||||||
ADD_HOST_OPTION_ID,
|
|
||||||
ALL_HOSTS_OPTION_ID,
|
|
||||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
|
||||||
getHostPickerLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEARCHABLE_THRESHOLD = 10;
|
const SEARCHABLE_THRESHOLD = 10;
|
||||||
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||||
@@ -121,12 +115,6 @@ export function HostPickerOption({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
|
|
||||||
add: "Add host",
|
|
||||||
all: "All hosts",
|
|
||||||
enableBuiltInDaemon: "Enable built-in daemon",
|
|
||||||
};
|
|
||||||
|
|
||||||
function SystemHostPickerOption({
|
function SystemHostPickerOption({
|
||||||
active,
|
active,
|
||||||
selected,
|
selected,
|
||||||
@@ -137,12 +125,12 @@ function SystemHostPickerOption({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
kind: "add" | "all" | "enableBuiltInDaemon";
|
kind: "add" | "all";
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const Icon = kind === "add" ? Plus : Server;
|
const Icon = kind === "add" ? Plus : Server;
|
||||||
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
|
const label = kind === "add" ? "Add host" : "All hosts";
|
||||||
const leadingSlot = useMemo(
|
const leadingSlot = useMemo(
|
||||||
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||||
@@ -170,13 +158,11 @@ export interface HostPickerProps {
|
|||||||
includeAllHost?: boolean;
|
includeAllHost?: boolean;
|
||||||
includeAddHost?: boolean;
|
includeAddHost?: boolean;
|
||||||
onAddHost?: () => void;
|
onAddHost?: () => void;
|
||||||
includeEnableBuiltInDaemon?: boolean;
|
|
||||||
onEnableBuiltInDaemon?: () => void;
|
|
||||||
showActiveConnection?: boolean;
|
showActiveConnection?: boolean;
|
||||||
onOpenHostSettings?: (serverId: string) => void;
|
onOpenHostSettings?: (serverId: string) => void;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
desktopMinWidth?: number;
|
desktopMinWidth?: number;
|
||||||
addHostTestID?: string;
|
addHostTestID?: string;
|
||||||
hostOptionTestID?: (serverId: string) => string;
|
hostOptionTestID?: (serverId: string) => string;
|
||||||
@@ -193,13 +179,11 @@ export function HostPicker({
|
|||||||
includeAllHost,
|
includeAllHost,
|
||||||
includeAddHost,
|
includeAddHost,
|
||||||
onAddHost,
|
onAddHost,
|
||||||
includeEnableBuiltInDaemon,
|
|
||||||
onEnableBuiltInDaemon,
|
|
||||||
showActiveConnection,
|
showActiveConnection,
|
||||||
onOpenHostSettings,
|
onOpenHostSettings,
|
||||||
searchable,
|
searchable,
|
||||||
title,
|
title,
|
||||||
desktopPlacement = "bottom-start",
|
desktopPlacement = "top-start",
|
||||||
desktopMinWidth,
|
desktopMinWidth,
|
||||||
addHostTestID,
|
addHostTestID,
|
||||||
hostOptionTestID,
|
hostOptionTestID,
|
||||||
@@ -215,13 +199,8 @@ export function HostPicker({
|
|||||||
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||||
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
||||||
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
||||||
if (includeEnableBuiltInDaemon)
|
|
||||||
hostOptions.push({
|
|
||||||
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
|
||||||
label: "Enable built-in daemon",
|
|
||||||
});
|
|
||||||
return hostOptions;
|
return hostOptions;
|
||||||
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
|
}, [orderedHosts, includeAllHost, includeAddHost]);
|
||||||
|
|
||||||
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
||||||
|
|
||||||
@@ -229,14 +208,12 @@ export function HostPicker({
|
|||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (id === ADD_HOST_OPTION_ID) {
|
if (id === ADD_HOST_OPTION_ID) {
|
||||||
onAddHost?.();
|
onAddHost?.();
|
||||||
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
|
||||||
onEnableBuiltInDaemon?.();
|
|
||||||
} else {
|
} else {
|
||||||
onSelect(id);
|
onSelect(id);
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
|
[onAddHost, onOpenChange, onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenHostSettings = useCallback(
|
const handleOpenHostSettings = useCallback(
|
||||||
@@ -269,11 +246,6 @@ export function HostPicker({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
|
||||||
return (
|
|
||||||
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<HostPickerOption
|
<HostPickerOption
|
||||||
serverId={option.id}
|
serverId={option.id}
|
||||||
|
|||||||
@@ -385,7 +385,6 @@ function SidebarHostPicker({
|
|||||||
showActiveConnection
|
showActiveConnection
|
||||||
onOpenHostSettings={onOpenHostSettings}
|
onOpenHostSettings={onOpenHostSettings}
|
||||||
searchable
|
searchable
|
||||||
desktopPlacement="top-start"
|
|
||||||
desktopMinWidth={240}
|
desktopMinWidth={240}
|
||||||
addHostTestID="sidebar-host-add"
|
addHostTestID="sidebar-host-add"
|
||||||
hostOptionTestID={sidebarHostOptionTestID}
|
hostOptionTestID={sidebarHostOptionTestID}
|
||||||
|
|||||||
@@ -1,140 +1,366 @@
|
|||||||
import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
|
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import type { PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import type { FieldControlSize } from "@/components/ui/control-geometry";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
|
||||||
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
|
|
||||||
import {
|
import {
|
||||||
CADENCE_PRESET_OPTIONS,
|
describeCron,
|
||||||
normalizeScheduleFormCadence,
|
everyMsToParts,
|
||||||
resolveCronPresetDisplay,
|
type IntervalUnit,
|
||||||
resolveCronPresetId,
|
partsToEveryMs,
|
||||||
} from "@/schedules/schedule-cadence-options";
|
validateCron,
|
||||||
import { getDeviceTimeZone } from "@/utils/device-timezone";
|
} from "@/utils/schedule-format";
|
||||||
import { describeCron, validateCron } from "@/utils/schedule-format";
|
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
type CadenceMode = ScheduleCadence["type"];
|
||||||
|
|
||||||
|
interface CronPreset {
|
||||||
|
label: string;
|
||||||
|
expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5-field expressions evaluated in UTC by the daemon. Each one round-trips
|
||||||
|
// through describeCron() so the chip and the live preview agree.
|
||||||
|
const CRON_PRESETS: CronPreset[] = [
|
||||||
|
{ label: "Every hour", expression: "0 * * * *" },
|
||||||
|
{ label: "Daily 9:00", expression: "0 9 * * *" },
|
||||||
|
{ label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
|
||||||
|
{ label: "Mondays 9:00", expression: "0 9 * * 1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODE_OPTIONS = [
|
||||||
|
{ value: "every" as const, label: "Interval" },
|
||||||
|
{ value: "cron" as const, label: "Cron" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNIT_OPTIONS = [
|
||||||
|
{ value: "minutes" as const, label: "Minutes" },
|
||||||
|
{ value: "hours" as const, label: "Hours" },
|
||||||
|
{ value: "days" as const, label: "Days" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours");
|
||||||
|
const DEFAULT_CRON_EXPRESSION = "0 9 * * *";
|
||||||
|
|
||||||
|
const UNIT_NOUN: Record<IntervalUnit, string> = {
|
||||||
|
minutes: "minute",
|
||||||
|
hours: "hour",
|
||||||
|
days: "day",
|
||||||
|
};
|
||||||
|
|
||||||
|
function describeInterval(value: number, unit: IntervalUnit): string {
|
||||||
|
const noun = UNIT_NOUN[unit];
|
||||||
|
if (value === 1) {
|
||||||
|
return `Runs every ${noun}`;
|
||||||
|
}
|
||||||
|
return `Runs every ${value} ${noun}s`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CadenceEditorProps {
|
export interface CadenceEditorProps {
|
||||||
value: ScheduleCadence;
|
value: ScheduleCadence;
|
||||||
onChange: (next: ScheduleCadence) => void;
|
onChange: (next: ScheduleCadence) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
size?: FieldControlSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
|
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||||
id: option.id,
|
const mode = value.type;
|
||||||
value: option.id,
|
|
||||||
label: option.label,
|
|
||||||
testID: `schedule-cadence-preset-${option.id}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
|
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
|
||||||
if (error || !expression) {
|
// once from the incoming cadence via lazy state initializers and bump
|
||||||
return null;
|
// resetKey only when we change the content ourselves (mode switch, preset
|
||||||
}
|
// chip) — never on every keystroke.
|
||||||
return describeCron({ type: "cron", expression, timezone }) ?? expression;
|
const [intervalValueText, setIntervalValueText] = useState(() =>
|
||||||
}
|
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
|
||||||
|
|
||||||
function buildCronCadence(expression: string, timezone: string): CronCadence {
|
|
||||||
return { type: "cron", expression, timezone };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEditorProps) {
|
|
||||||
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
|
|
||||||
const normalizedValue = normalizeScheduleFormCadence(value, deviceTimeZone);
|
|
||||||
const [cronText, setCronText] = useState(() => normalizedValue.expression);
|
|
||||||
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
|
|
||||||
const cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
|
|
||||||
const trimmedCron = cronText.trim();
|
|
||||||
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
|
|
||||||
const effectiveError = error ?? localCronError;
|
|
||||||
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
|
|
||||||
const currentCadence = useMemo<CronCadence>(
|
|
||||||
() => buildCronCadence(trimmedCron, cronTimeZone),
|
|
||||||
[cronTimeZone, trimmedCron],
|
|
||||||
);
|
);
|
||||||
const selectedPresetId = resolveCronPresetId(currentCadence);
|
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
|
||||||
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
|
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
|
||||||
|
);
|
||||||
|
const [cronText, setCronText] = useState(() =>
|
||||||
|
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
||||||
|
);
|
||||||
|
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
|
||||||
|
|
||||||
const handlePresetChange = useCallback(
|
// Remember the cron expression the user had so toggling Interval -> Cron and
|
||||||
(presetId: string) => {
|
// back does not discard a blanked-out field. Interval mode rebuilds straight
|
||||||
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
|
// from the live numeric value + unit, so it needs no equivalent ref.
|
||||||
if (!preset) {
|
const lastCronExpression = useRef(
|
||||||
|
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedIntervalValue = useMemo(() => {
|
||||||
|
const parsed = Number.parseInt(intervalValueText, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||||
|
}, [intervalValueText]);
|
||||||
|
|
||||||
|
const emitInterval = useCallback(
|
||||||
|
(rawValue: number, unit: IntervalUnit) => {
|
||||||
|
onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) });
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const emitCron = useCallback(
|
||||||
|
(expression: string) => {
|
||||||
|
lastCronExpression.current = expression;
|
||||||
|
onChange({ type: "cron", expression });
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModeChange = useCallback(
|
||||||
|
(nextMode: CadenceMode) => {
|
||||||
|
if (nextMode === mode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCronText(preset.expression);
|
if (nextMode === "every") {
|
||||||
bumpFieldResetKey();
|
emitInterval(parsedIntervalValue, intervalUnit);
|
||||||
onChange(buildCronCadence(preset.expression, cronTimeZone));
|
} else {
|
||||||
|
emitCron(cronText.trim() || lastCronExpression.current);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[cronTimeZone, onChange],
|
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIntervalValueChange = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
// Keep only digits so the cadence stays a positive integer count.
|
||||||
|
const digits = text.replace(/[^0-9]/g, "");
|
||||||
|
setIntervalValueText(digits);
|
||||||
|
const parsed = Number.parseInt(digits, 10);
|
||||||
|
emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit);
|
||||||
|
},
|
||||||
|
[emitInterval, intervalUnit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnitChange = useCallback(
|
||||||
|
(unit: IntervalUnit) => {
|
||||||
|
setIntervalUnit(unit);
|
||||||
|
emitInterval(parsedIntervalValue, unit);
|
||||||
|
},
|
||||||
|
[emitInterval, parsedIntervalValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCronChange = useCallback(
|
const handleCronChange = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
setCronText(text);
|
setCronText(text);
|
||||||
onChange(buildCronCadence(text.trim(), cronTimeZone));
|
emitCron(text.trim());
|
||||||
},
|
},
|
||||||
[cronTimeZone, onChange],
|
[emitCron],
|
||||||
);
|
);
|
||||||
|
|
||||||
let feedback: ReactNode = null;
|
const handlePresetPress = useCallback(
|
||||||
if (effectiveError) {
|
(expression: string) => {
|
||||||
feedback = <Text style={styles.error}>{effectiveError}</Text>;
|
setCronText(expression);
|
||||||
} else if (preview) {
|
bumpFieldResetKey();
|
||||||
feedback = <Text style={styles.preview}>{preview}</Text>;
|
emitCron(expression);
|
||||||
|
},
|
||||||
|
[emitCron],
|
||||||
|
);
|
||||||
|
|
||||||
|
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
|
||||||
|
const trimmedCron = cronText.trim();
|
||||||
|
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
|
||||||
|
const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron);
|
||||||
|
|
||||||
|
let cronFeedback: ReactNode = null;
|
||||||
|
if (cronError) {
|
||||||
|
cronFeedback = <Text style={styles.error}>{cronError}</Text>;
|
||||||
|
} else if (cronPreview) {
|
||||||
|
cronFeedback = <Text style={styles.preview}>{cronPreview}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field label="Cadence">
|
<View style={styles.container}>
|
||||||
<View style={styles.stack}>
|
<SegmentedControl
|
||||||
<SelectField
|
size="sm"
|
||||||
label="Cadence"
|
value={mode}
|
||||||
value={selectedPresetId === "custom" ? null : selectedPresetId}
|
onValueChange={handleModeChange}
|
||||||
selectedDisplay={selectedPresetDisplay}
|
options={MODE_OPTIONS}
|
||||||
options={PRESET_OPTIONS}
|
style={styles.modeControl}
|
||||||
onChange={handlePresetChange}
|
testID="cadence-mode"
|
||||||
placeholder="Select cadence"
|
/>
|
||||||
emptyText="No cadences found"
|
|
||||||
searchable={false}
|
|
||||||
title="Cadence"
|
|
||||||
size={size}
|
|
||||||
triggerTestID="schedule-cadence-preset-trigger"
|
|
||||||
field={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextInput
|
{mode === "every" ? (
|
||||||
size={size}
|
<View style={styles.section}>
|
||||||
testID="cadence-cron-expression"
|
<View style={styles.intervalRow}>
|
||||||
accessibilityLabel="Cron expression"
|
<AdaptiveTextInput
|
||||||
initialValue={cronText}
|
testID="cadence-interval-value"
|
||||||
resetKey={`cadence-cron-${fieldResetKey}`}
|
accessibilityLabel="Interval value"
|
||||||
value={cronText}
|
initialValue={intervalValueText}
|
||||||
onChangeText={handleCronChange}
|
resetKey={`cadence-interval-${fieldResetKey}`}
|
||||||
placeholder="0 9 * * *"
|
value={intervalValueText}
|
||||||
autoCapitalize="none"
|
onChangeText={handleIntervalValueChange}
|
||||||
autoCorrect={false}
|
keyboardType="number-pad"
|
||||||
spellCheck={false}
|
style={styles.intervalInput}
|
||||||
style={styles.cronInput}
|
/>
|
||||||
/>
|
<SegmentedControl
|
||||||
{feedback}
|
size="sm"
|
||||||
</View>
|
value={intervalUnit}
|
||||||
</Field>
|
onValueChange={handleUnitChange}
|
||||||
|
options={UNIT_OPTIONS}
|
||||||
|
testID="cadence-interval-unit"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.preview}>{intervalPreview}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.presetRow}>
|
||||||
|
{CRON_PRESETS.map((preset) => (
|
||||||
|
<CronPresetChip
|
||||||
|
key={preset.expression}
|
||||||
|
label={preset.label}
|
||||||
|
expression={preset.expression}
|
||||||
|
isSelected={trimmedCron === preset.expression}
|
||||||
|
onSelect={handlePresetPress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
testID="cadence-cron-expression"
|
||||||
|
accessibilityLabel="Cron expression"
|
||||||
|
initialValue={cronText}
|
||||||
|
resetKey={`cadence-cron-${fieldResetKey}`}
|
||||||
|
value={cronText}
|
||||||
|
onChangeText={handleCronChange}
|
||||||
|
placeholder="0 9 * * *"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
style={styles.cronInput}
|
||||||
|
/>
|
||||||
|
{cronFeedback}
|
||||||
|
<Text style={styles.hint}>Times are in UTC</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CronPresetChip({
|
||||||
|
label,
|
||||||
|
expression,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
expression: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: (expression: string) => void;
|
||||||
|
}) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onSelect(expression);
|
||||||
|
}, [onSelect, expression]);
|
||||||
|
const chipStyle = useCallback(
|
||||||
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
styles.chip,
|
||||||
|
isSelected && styles.chipSelected,
|
||||||
|
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
|
||||||
|
],
|
||||||
|
[isSelected],
|
||||||
|
);
|
||||||
|
const labelStyle = useMemo(
|
||||||
|
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
|
||||||
|
[isSelected],
|
||||||
|
);
|
||||||
|
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
onPress={handlePress}
|
||||||
|
style={chipStyle}
|
||||||
|
>
|
||||||
|
<Text style={labelStyle} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
stack: {
|
container: {
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
section: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
intervalRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
intervalInput: {
|
||||||
|
width: 88,
|
||||||
|
minHeight: 44,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
// The mode toggle hugs its options at the left rather than stretching to a
|
||||||
|
// full-width track; the interval row then reads as input + toggle.
|
||||||
|
modeControl: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
presetRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
minHeight: 32,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
chipHover: {
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
|
},
|
||||||
|
// Selected preset reads as a chosen surface, not a second accent fill
|
||||||
|
// competing with the sheet's primary CTA.
|
||||||
|
chipSelected: {
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
chipLabel: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
chipLabelSelected: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
cronInput: {
|
cronInput: {
|
||||||
|
minHeight: 44,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
fontFamily: theme.fontFamily.mono,
|
fontFamily: theme.fontFamily.mono,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
error: {
|
error: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.palette.red[300],
|
color: theme.colors.palette.red[300],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,10 @@ import type {
|
|||||||
TextStyle,
|
TextStyle,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
|
||||||
buttonIconSize,
|
|
||||||
createControlGeometry,
|
|
||||||
type ButtonControlSize,
|
|
||||||
} from "@/components/ui/control-geometry";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
|
|
||||||
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
||||||
type ButtonSize = ButtonControlSize;
|
type ButtonSize = "xs" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
type LeftIcon =
|
type LeftIcon =
|
||||||
| ReactElement
|
| ReactElement
|
||||||
@@ -33,130 +27,85 @@ type LeftIcon =
|
|||||||
| ((color: string) => ReactElement)
|
| ((color: string) => ReactElement)
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
interface ButtonIconProps {
|
const ICON_SIZE: Record<ButtonSize, number> = { xs: 12, sm: 14, md: 16, lg: 20 };
|
||||||
loading: boolean;
|
|
||||||
leftIcon?: LeftIcon;
|
|
||||||
iconSize: number;
|
|
||||||
iconColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps) {
|
const styles = StyleSheet.create((theme) => ({
|
||||||
if (loading) {
|
base: {
|
||||||
return (
|
flexDirection: "row",
|
||||||
<View>
|
alignItems: "center",
|
||||||
<ActivityIndicator size="small" color={iconColor} />
|
justifyContent: "center",
|
||||||
</View>
|
gap: theme.spacing[2],
|
||||||
);
|
borderRadius: theme.borderRadius.lg,
|
||||||
}
|
borderWidth: 1,
|
||||||
|
borderColor: "transparent",
|
||||||
if (!leftIcon) return null;
|
},
|
||||||
|
md: {
|
||||||
if (typeof leftIcon === "object" && "type" in leftIcon) {
|
paddingVertical: theme.spacing[3],
|
||||||
return <View>{leftIcon}</View>;
|
paddingHorizontal: theme.spacing[4],
|
||||||
}
|
},
|
||||||
|
xs: {
|
||||||
if (
|
minHeight: 28,
|
||||||
typeof leftIcon === "function" &&
|
paddingVertical: theme.spacing[1],
|
||||||
!leftIcon.prototype?.isReactComponent &&
|
paddingHorizontal: theme.spacing[3],
|
||||||
leftIcon.length > 0
|
borderRadius: theme.borderRadius.md,
|
||||||
) {
|
},
|
||||||
return <View>{(leftIcon as (color: string) => ReactElement)(iconColor)}</View>;
|
sm: {
|
||||||
}
|
paddingVertical: theme.spacing[1.5],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
|
borderRadius: theme.borderRadius.md,
|
||||||
return (
|
},
|
||||||
<View>
|
lg: {
|
||||||
<Icon color={iconColor} size={iconSize} />
|
paddingVertical: theme.spacing[4],
|
||||||
</View>
|
paddingHorizontal: theme.spacing[6],
|
||||||
);
|
borderRadius: theme.borderRadius.xl,
|
||||||
}
|
},
|
||||||
|
default: {
|
||||||
const ThemedButtonIcon = withUnistyles(ButtonIcon);
|
backgroundColor: theme.colors.accent,
|
||||||
|
borderColor: theme.colors.accent,
|
||||||
const foregroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
},
|
||||||
const foregroundMutedIconMapping = (theme: Theme) => ({
|
secondary: {
|
||||||
iconColor: theme.colors.foregroundMuted,
|
backgroundColor: theme.colors.surface3,
|
||||||
});
|
borderColor: theme.colors.surface3,
|
||||||
const accentForegroundIconMapping = (theme: Theme) => ({
|
},
|
||||||
iconColor: theme.colors.accentForeground,
|
outline: {
|
||||||
});
|
backgroundColor: "transparent",
|
||||||
const destructiveForegroundIconMapping = (theme: Theme) => ({
|
borderColor: theme.colors.borderAccent,
|
||||||
iconColor: theme.colors.destructiveForeground,
|
},
|
||||||
});
|
ghost: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
const styles = StyleSheet.create((theme) => {
|
borderColor: "transparent",
|
||||||
const geometry = createControlGeometry(theme);
|
},
|
||||||
|
destructive: {
|
||||||
return {
|
backgroundColor: theme.colors.destructive,
|
||||||
base: {
|
borderColor: theme.colors.destructive,
|
||||||
flexDirection: "row",
|
},
|
||||||
alignItems: "center",
|
pressed: {
|
||||||
justifyContent: "center",
|
opacity: 0.85,
|
||||||
gap: theme.spacing[2],
|
},
|
||||||
borderRadius: theme.borderRadius.lg,
|
disabled: {
|
||||||
borderWidth: 1,
|
opacity: theme.opacity[50],
|
||||||
borderColor: "transparent",
|
},
|
||||||
},
|
text: {
|
||||||
md: {
|
color: theme.colors.foreground,
|
||||||
...geometry.buttonMd,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
fontWeight: theme.fontWeight.normal,
|
||||||
xs: {
|
},
|
||||||
...geometry.buttonXs,
|
textXs: {
|
||||||
},
|
fontSize: theme.fontSize.xs,
|
||||||
sm: {
|
},
|
||||||
...geometry.buttonSm,
|
textDefault: {
|
||||||
},
|
color: theme.colors.accentForeground,
|
||||||
lg: {
|
},
|
||||||
...geometry.buttonLg,
|
textDestructive: {
|
||||||
},
|
color: theme.colors.destructiveForeground,
|
||||||
default: {
|
},
|
||||||
backgroundColor: theme.colors.accent,
|
textGhost: {
|
||||||
borderColor: theme.colors.accent,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
secondary: {
|
textGhostHovered: {
|
||||||
backgroundColor: theme.colors.surface3,
|
color: theme.colors.foreground,
|
||||||
borderColor: theme.colors.surface3,
|
},
|
||||||
},
|
}));
|
||||||
outline: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
ghost: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
borderColor: "transparent",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
backgroundColor: theme.colors.destructive,
|
|
||||||
borderColor: theme.colors.destructive,
|
|
||||||
},
|
|
||||||
pressed: {
|
|
||||||
opacity: 0.85,
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
...geometry.buttonText,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
|
||||||
textXs: {
|
|
||||||
...geometry.buttonTextXs,
|
|
||||||
},
|
|
||||||
textDefault: {
|
|
||||||
color: theme.colors.accentForeground,
|
|
||||||
},
|
|
||||||
textDestructive: {
|
|
||||||
color: theme.colors.destructiveForeground,
|
|
||||||
},
|
|
||||||
textGhost: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
textGhostHovered: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
children,
|
children,
|
||||||
@@ -169,7 +118,6 @@ export function Button({
|
|||||||
disabled,
|
disabled,
|
||||||
loading = false,
|
loading = false,
|
||||||
accessibilityRole,
|
accessibilityRole,
|
||||||
accessibilityState: accessibilityStateProp,
|
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
Omit<PressableProps, "style"> & {
|
Omit<PressableProps, "style"> & {
|
||||||
@@ -183,6 +131,7 @@ export function Button({
|
|||||||
}
|
}
|
||||||
>) {
|
>) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const isDisabled = disabled || loading;
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
let variantStyle: ViewStyle;
|
let variantStyle: ViewStyle;
|
||||||
@@ -239,21 +188,55 @@ export function Button({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
() => ({ ...accessibilityStateProp, disabled: isDisabled, busy: loading }),
|
() => ({ disabled: isDisabled, busy: loading }),
|
||||||
[accessibilityStateProp, isDisabled, loading],
|
[isDisabled, loading],
|
||||||
);
|
);
|
||||||
|
|
||||||
function resolveIconMapping() {
|
function resolveIconColor(): string {
|
||||||
if (variant === "default") {
|
if (variant === "default") {
|
||||||
return accentForegroundIconMapping;
|
return theme.colors.accentForeground;
|
||||||
}
|
|
||||||
if (variant === "destructive") {
|
|
||||||
return destructiveForegroundIconMapping;
|
|
||||||
}
|
}
|
||||||
if (variant === "ghost") {
|
if (variant === "ghost") {
|
||||||
return isGhostHovered ? foregroundIconMapping : foregroundMutedIconMapping;
|
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
}
|
}
|
||||||
return foregroundIconMapping;
|
return theme.colors.foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIcon() {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ActivityIndicator size="small" color={resolveIconColor()} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!leftIcon) return null;
|
||||||
|
|
||||||
|
// Pre-rendered element — pass through
|
||||||
|
if (typeof leftIcon === "object" && "type" in leftIcon) {
|
||||||
|
return <View>{leftIcon}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = resolveIconColor();
|
||||||
|
const iconSize = ICON_SIZE[size];
|
||||||
|
|
||||||
|
// Render function
|
||||||
|
if (
|
||||||
|
typeof leftIcon === "function" &&
|
||||||
|
!leftIcon.prototype?.isReactComponent &&
|
||||||
|
leftIcon.length > 0
|
||||||
|
) {
|
||||||
|
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component type
|
||||||
|
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Icon color={color} size={iconSize} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -266,12 +249,7 @@ export function Button({
|
|||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
<ThemedButtonIcon
|
{renderIcon()}
|
||||||
loading={loading}
|
|
||||||
leftIcon={leftIcon}
|
|
||||||
iconSize={buttonIconSize[size]}
|
|
||||||
uniProps={resolveIconMapping()}
|
|
||||||
/>
|
|
||||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||||
{trailing}
|
{trailing}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { ViewStyle } from "react-native";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
|
||||||
|
|
||||||
function buildWidthStyle(input: {
|
|
||||||
desktopMinWidth?: number;
|
|
||||||
referenceWidth: number | null;
|
|
||||||
}): Pick<ViewStyle, "width" | "minWidth" | "maxWidth"> {
|
|
||||||
const [frameStyle] = buildDesktopFrameStyle({
|
|
||||||
desktopMinWidth: input.desktopMinWidth,
|
|
||||||
referenceWidth: input.referenceWidth,
|
|
||||||
desktopFixedHeight: undefined,
|
|
||||||
desktopPositionStyle: { left: 0, top: 0 },
|
|
||||||
shouldHideDesktopContent: false,
|
|
||||||
availableHeight: undefined,
|
|
||||||
}) as ViewStyle[];
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: frameStyle.width,
|
|
||||||
minWidth: frameStyle.minWidth,
|
|
||||||
maxWidth: frameStyle.maxWidth,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("buildDesktopFrameStyle", () => {
|
|
||||||
it("lets a narrow trigger grow to the default desktop ceiling", () => {
|
|
||||||
expect(buildWidthStyle({ referenceWidth: 120 })).toEqual({
|
|
||||||
width: undefined,
|
|
||||||
minWidth: 120,
|
|
||||||
maxWidth: 400,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a wide trigger from being capped below its own width", () => {
|
|
||||||
expect(buildWidthStyle({ referenceWidth: 470 })).toEqual({
|
|
||||||
width: undefined,
|
|
||||||
minWidth: 470,
|
|
||||||
maxWidth: 470,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses desktopMinWidth as an explicit floor raiser", () => {
|
|
||||||
expect(buildWidthStyle({ desktopMinWidth: 360, referenceWidth: 120 })).toEqual({
|
|
||||||
width: undefined,
|
|
||||||
minWidth: 360,
|
|
||||||
maxWidth: 400,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the trigger as the floor when it is wider than desktopMinWidth", () => {
|
|
||||||
expect(buildWidthStyle({ desktopMinWidth: 240, referenceWidth: 300 })).toEqual({
|
|
||||||
width: undefined,
|
|
||||||
minWidth: 300,
|
|
||||||
maxWidth: 400,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import type { StyleProp, ViewStyle } from "react-native";
|
|
||||||
|
|
||||||
export interface DesktopFrameStyleInput {
|
|
||||||
desktopMinWidth: number | undefined;
|
|
||||||
referenceWidth: number | null;
|
|
||||||
desktopFixedHeight: number | undefined;
|
|
||||||
desktopPositionStyle: StyleProp<ViewStyle>;
|
|
||||||
shouldHideDesktopContent: boolean;
|
|
||||||
availableHeight: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp<ViewStyle> {
|
|
||||||
const {
|
|
||||||
desktopMinWidth,
|
|
||||||
referenceWidth,
|
|
||||||
desktopFixedHeight,
|
|
||||||
desktopPositionStyle,
|
|
||||||
shouldHideDesktopContent,
|
|
||||||
availableHeight,
|
|
||||||
} = input;
|
|
||||||
const fixedHeightStyle =
|
|
||||||
desktopFixedHeight != null
|
|
||||||
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
|
|
||||||
: null;
|
|
||||||
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
|
|
||||||
const availableHeightStyle =
|
|
||||||
typeof availableHeight === "number"
|
|
||||||
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
|
|
||||||
: null;
|
|
||||||
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
position: "absolute" as const,
|
|
||||||
minWidth: floor,
|
|
||||||
maxWidth: Math.max(400, floor),
|
|
||||||
},
|
|
||||||
fixedHeightStyle,
|
|
||||||
desktopPositionStyle,
|
|
||||||
hiddenStyle,
|
|
||||||
availableHeightStyle,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
import {
|
import { forwardRef, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ReactElement,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
Pressable,
|
Pressable,
|
||||||
View,
|
View,
|
||||||
type NativeSyntheticEvent,
|
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
type TargetedEvent,
|
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -29,7 +20,6 @@ const chevronColorMapping = (theme: Theme) => ({
|
|||||||
interface TriggerState {
|
interface TriggerState {
|
||||||
pressed: boolean;
|
pressed: boolean;
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
focused: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
|
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
|
||||||
@@ -46,45 +36,23 @@ interface ComboboxTriggerProps extends Omit<PressableProps, "style" | "children"
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
|
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
|
||||||
{ children, chevron, style, block = false, onFocus, onBlur, ...props },
|
{ children, chevron, style, block = false, ...props },
|
||||||
ref,
|
ref,
|
||||||
): ReactElement {
|
): ReactElement {
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
if (typeof style === "function") {
|
if (typeof style === "function") {
|
||||||
return style({ pressed, hovered, focused });
|
return style({ pressed, hovered });
|
||||||
}
|
}
|
||||||
return style;
|
return style;
|
||||||
},
|
},
|
||||||
[focused, style],
|
[style],
|
||||||
);
|
|
||||||
const handleFocus = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<TargetedEvent>) => {
|
|
||||||
setFocused(true);
|
|
||||||
onFocus?.(event);
|
|
||||||
},
|
|
||||||
[onFocus],
|
|
||||||
);
|
|
||||||
const handleBlur = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<TargetedEvent>) => {
|
|
||||||
setFocused(false);
|
|
||||||
onBlur?.(event);
|
|
||||||
},
|
|
||||||
[onBlur],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
|
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable ref={ref} collapsable={false} style={pressableStyle} {...props}>
|
||||||
ref={ref}
|
|
||||||
collapsable={false}
|
|
||||||
style={pressableStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
{children}
|
{children}
|
||||||
{chevron !== null &&
|
{chevron !== null &&
|
||||||
|
|||||||
@@ -61,14 +61,10 @@ import {
|
|||||||
} from "@/components/adaptive-modal-sheet";
|
} from "@/components/adaptive-modal-sheet";
|
||||||
import { FloatingSurface } from "@/components/ui/floating";
|
import { FloatingSurface } from "@/components/ui/floating";
|
||||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||||
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
|
||||||
|
|
||||||
export { buildDesktopFrameStyle } from "./combobox-frame-style";
|
|
||||||
|
|
||||||
const IS_WEB = isWeb;
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export type ComboboxOption = ComboboxOptionModel;
|
export type ComboboxOption = ComboboxOptionModel;
|
||||||
export type ComboboxDesktopPlacement = "top-start" | "bottom-start";
|
|
||||||
|
|
||||||
export interface ComboboxProps {
|
export interface ComboboxProps {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
@@ -103,7 +99,7 @@ export interface ComboboxProps {
|
|||||||
presentation?: "push" | "replace";
|
presentation?: "push" | "replace";
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
desktopPlacement?: ComboboxDesktopPlacement;
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
/**
|
/**
|
||||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||||
* coordinates resolve. This intentionally disables fade enter/exit animation
|
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||||
@@ -385,7 +381,7 @@ function OptionsList({
|
|||||||
interface DesktopPositionInput {
|
interface DesktopPositionInput {
|
||||||
isDesktopAboveSearch: boolean;
|
isDesktopAboveSearch: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
desktopPlacement: ComboboxDesktopPlacement;
|
desktopPlacement: "top-start" | "bottom-start";
|
||||||
referenceTop: number | null;
|
referenceTop: number | null;
|
||||||
referenceLeft: number | null;
|
referenceLeft: number | null;
|
||||||
referenceAtOrigin: boolean;
|
referenceAtOrigin: boolean;
|
||||||
@@ -660,7 +656,7 @@ interface DesktopResetSetters {
|
|||||||
function useDesktopPositionReset(
|
function useDesktopPositionReset(
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
desktopPlacement: ComboboxDesktopPlacement,
|
desktopPlacement: "top-start" | "bottom-start",
|
||||||
update: () => unknown,
|
update: () => unknown,
|
||||||
setters: DesktopResetSetters,
|
setters: DesktopResetSetters,
|
||||||
) {
|
) {
|
||||||
@@ -854,6 +850,46 @@ function buildFloatingMiddleware(input: FloatingMiddlewareInput) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DesktopContainerStyleInput {
|
||||||
|
desktopMinWidth: number | undefined;
|
||||||
|
referenceWidth: number | null;
|
||||||
|
desktopFixedHeight: number | undefined;
|
||||||
|
desktopPositionStyle: DesktopPositionResult["desktopPositionStyle"];
|
||||||
|
shouldHideDesktopContent: boolean;
|
||||||
|
availableHeight: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
|
||||||
|
const {
|
||||||
|
desktopMinWidth,
|
||||||
|
referenceWidth,
|
||||||
|
desktopFixedHeight,
|
||||||
|
desktopPositionStyle,
|
||||||
|
shouldHideDesktopContent,
|
||||||
|
availableHeight,
|
||||||
|
} = input;
|
||||||
|
const fixedHeightStyle =
|
||||||
|
desktopFixedHeight != null
|
||||||
|
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
|
||||||
|
: null;
|
||||||
|
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
|
||||||
|
const availableHeightStyle =
|
||||||
|
typeof availableHeight === "number"
|
||||||
|
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
|
||||||
|
: null;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
position: "absolute" as const,
|
||||||
|
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
|
||||||
|
maxWidth: Math.max(400, desktopMinWidth ?? 0),
|
||||||
|
},
|
||||||
|
fixedHeightStyle,
|
||||||
|
desktopPositionStyle,
|
||||||
|
hiddenStyle,
|
||||||
|
availableHeightStyle,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function isDesktopKey(key: string): key is DesktopKey {
|
function isDesktopKey(key: string): key is DesktopKey {
|
||||||
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
|
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
|
||||||
}
|
}
|
||||||
@@ -1219,7 +1255,7 @@ export function Combobox({
|
|||||||
presentation,
|
presentation,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
desktopPlacement = "bottom-start",
|
desktopPlacement = "top-start",
|
||||||
desktopPreventInitialFlash = true,
|
desktopPreventInitialFlash = true,
|
||||||
desktopMinWidth,
|
desktopMinWidth,
|
||||||
desktopFixedHeight,
|
desktopFixedHeight,
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
createControlGeometry,
|
|
||||||
getControlInteractionPhase,
|
|
||||||
} from "@/components/ui/control-geometry";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
|
|
||||||
const theme = {
|
|
||||||
borderRadius: {
|
|
||||||
md: 6,
|
|
||||||
lg: 8,
|
|
||||||
xl: 12,
|
|
||||||
},
|
|
||||||
borderWidth: {
|
|
||||||
1: 1,
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
accent: "#20744A",
|
|
||||||
borderAccent: "#2F3534",
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
xs: 12,
|
|
||||||
sm: 14,
|
|
||||||
base: 16,
|
|
||||||
},
|
|
||||||
opacity: {
|
|
||||||
50: 0.5,
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
0: 0,
|
|
||||||
3: 12,
|
|
||||||
4: 16,
|
|
||||||
6: 24,
|
|
||||||
},
|
|
||||||
} as unknown as Theme;
|
|
||||||
|
|
||||||
describe("control geometry", () => {
|
|
||||||
it("keeps resting control borders transparent while preserving border geometry", () => {
|
|
||||||
const geometry = createControlGeometry(theme);
|
|
||||||
|
|
||||||
expect(geometry.controlRest).toMatchObject({
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "transparent",
|
|
||||||
outlineColor: "transparent",
|
|
||||||
outlineWidth: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the shared hover border and active focus ring values", () => {
|
|
||||||
const geometry = createControlGeometry(theme);
|
|
||||||
|
|
||||||
expect(geometry.controlHover).toEqual({
|
|
||||||
borderColor: "#2F3534",
|
|
||||||
});
|
|
||||||
expect(geometry.controlActive).toEqual({
|
|
||||||
borderColor: "#2F3534",
|
|
||||||
outlineColor: "#20744A",
|
|
||||||
outlineOffset: 1,
|
|
||||||
outlineStyle: "solid",
|
|
||||||
outlineWidth: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves disabled, focus, open, pressed, and hover into one interaction phase", () => {
|
|
||||||
expect(getControlInteractionPhase({ disabled: true, focused: true })).toBe("rest");
|
|
||||||
expect(getControlInteractionPhase({ focused: true })).toBe("active");
|
|
||||||
expect(getControlInteractionPhase({ open: true })).toBe("active");
|
|
||||||
expect(getControlInteractionPhase({ pressed: true })).toBe("active");
|
|
||||||
expect(getControlInteractionPhase({ hovered: true })).toBe("hover");
|
|
||||||
expect(getControlInteractionPhase({})).toBe("rest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps field text sizing tied to control size", () => {
|
|
||||||
const geometry = createControlGeometry(theme);
|
|
||||||
|
|
||||||
expect(geometry.fieldTextSm.fontSize).toBe(14);
|
|
||||||
expect(geometry.fieldTextSm.lineHeight).toBe(20);
|
|
||||||
expect(geometry.fieldTextMd.fontSize).toBe(16);
|
|
||||||
expect(geometry.fieldTextMd.lineHeight).toBe(22);
|
|
||||||
expect(geometry.formTextInputSm.fontSize).toBe(14);
|
|
||||||
expect(geometry.formTextInputSm.lineHeight).toBe(20);
|
|
||||||
expect(geometry.formTextInputMd.fontSize).toBe(16);
|
|
||||||
expect(geometry.formTextInputMd.lineHeight).toBe(22);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives field padding from line height without changing the control height", () => {
|
|
||||||
const geometry = createControlGeometry(theme);
|
|
||||||
|
|
||||||
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
|
||||||
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
|
|
||||||
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
|
|
||||||
geometry.fieldControlSm.minHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(geometry.fieldControlMd.minHeight).toBe(44);
|
|
||||||
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
|
|
||||||
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
|
|
||||||
geometry.fieldControlMd.minHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
|
|
||||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subtracts segmented control inset from the nested segment radius", () => {
|
|
||||||
const geometry = createControlGeometry(theme);
|
|
||||||
|
|
||||||
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
|
||||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
|
||||||
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
|
||||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import type { StyleProp, ViewStyle } from "react-native";
|
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
||||||
|
|
||||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
|
||||||
export type FieldControlSize = "sm" | "md";
|
|
||||||
export type SegmentedControlSize = "sm" | "md";
|
|
||||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
|
||||||
|
|
||||||
export interface ControlInteractionState {
|
|
||||||
hovered?: boolean;
|
|
||||||
focused?: boolean;
|
|
||||||
pressed?: boolean;
|
|
||||||
open?: boolean;
|
|
||||||
active?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControlInteractionStyleMap {
|
|
||||||
controlRest: StyleProp<ViewStyle>;
|
|
||||||
controlHover: StyleProp<ViewStyle>;
|
|
||||||
controlActive: StyleProp<ViewStyle>;
|
|
||||||
controlDisabled?: StyleProp<ViewStyle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPACT_CONTROL_HEIGHT = 32;
|
|
||||||
const FIELD_CONTROL_HEIGHT = 44;
|
|
||||||
const SEGMENTED_COMPACT_INSET = 2;
|
|
||||||
const SEGMENTED_FIELD_INSET = 3;
|
|
||||||
const SWITCH_TRACK_WIDTH = 34;
|
|
||||||
const SWITCH_TRACK_HEIGHT = 20;
|
|
||||||
const SWITCH_THUMB_SIZE = 16;
|
|
||||||
const CONTROL_FOCUS_RING_WIDTH = 2;
|
|
||||||
const CONTROL_FOCUS_RING_OFFSET = 1;
|
|
||||||
const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
|
|
||||||
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
|
||||||
|
|
||||||
const controlHeights = {
|
|
||||||
compact: COMPACT_CONTROL_HEIGHT,
|
|
||||||
field: FIELD_CONTROL_HEIGHT,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buttonIconSize: Record<ButtonControlSize, number> = {
|
|
||||||
xs: ICON_SIZE.xs,
|
|
||||||
sm: ICON_SIZE.sm,
|
|
||||||
md: ICON_SIZE.md,
|
|
||||||
lg: ICON_SIZE.lg,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
|
||||||
sm: ICON_SIZE.sm,
|
|
||||||
md: ICON_SIZE.md,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const switchGeometry = {
|
|
||||||
trackWidth: SWITCH_TRACK_WIDTH,
|
|
||||||
trackHeight: SWITCH_TRACK_HEIGHT,
|
|
||||||
thumbSize: SWITCH_THUMB_SIZE,
|
|
||||||
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
|
||||||
};
|
|
||||||
|
|
||||||
function nestedRadius(containerRadius: number, inset: number): number {
|
|
||||||
return Math.max(0, containerRadius - inset);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldLineHeight(fontSize: number): number {
|
|
||||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
|
|
||||||
return (controlHeight - lineHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getControlInteractionPhase(
|
|
||||||
state: ControlInteractionState,
|
|
||||||
): ControlInteractionPhase {
|
|
||||||
if (state.disabled) {
|
|
||||||
return "rest";
|
|
||||||
}
|
|
||||||
if (state.active || state.focused || state.open || state.pressed) {
|
|
||||||
return "active";
|
|
||||||
}
|
|
||||||
if (state.hovered) {
|
|
||||||
return "hover";
|
|
||||||
}
|
|
||||||
return "rest";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveControlInteractionStyles(
|
|
||||||
styles: ControlInteractionStyleMap,
|
|
||||||
state: ControlInteractionState,
|
|
||||||
): StyleProp<ViewStyle> {
|
|
||||||
const phase = getControlInteractionPhase(state);
|
|
||||||
return [
|
|
||||||
styles.controlRest,
|
|
||||||
phase === "hover" ? styles.controlHover : null,
|
|
||||||
phase === "active" ? styles.controlActive : null,
|
|
||||||
state.disabled ? styles.controlDisabled : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createControlGeometry(theme: Theme) {
|
|
||||||
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.sm);
|
|
||||||
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
|
||||||
const fieldControlSm = {
|
|
||||||
minHeight: controlHeights.compact,
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: fieldVerticalPadding(controlHeights.compact, fieldTextSmLineHeight),
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
};
|
|
||||||
const fieldControlMd = {
|
|
||||||
minHeight: controlHeights.field,
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: fieldVerticalPadding(controlHeights.field, fieldTextMdLineHeight),
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
};
|
|
||||||
const fieldTextSm = {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
lineHeight: fieldTextSmLineHeight,
|
|
||||||
};
|
|
||||||
const fieldTextMd = {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
lineHeight: fieldTextMdLineHeight,
|
|
||||||
};
|
|
||||||
const segmentedContainerSmRadius = theme.borderRadius.md;
|
|
||||||
const segmentedContainerMdRadius = theme.borderRadius.lg;
|
|
||||||
const switchControl = {
|
|
||||||
minHeight: controlHeights.compact,
|
|
||||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
|
||||||
} satisfies { minHeight: number; justifyContent: "center" };
|
|
||||||
|
|
||||||
return {
|
|
||||||
buttonXs: {
|
|
||||||
minHeight: controlHeights.compact,
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
|
||||||
buttonSm: {
|
|
||||||
minHeight: controlHeights.compact,
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
|
||||||
buttonMd: {
|
|
||||||
minHeight: controlHeights.field,
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
buttonLg: {
|
|
||||||
minHeight: controlHeights.field,
|
|
||||||
paddingHorizontal: theme.spacing[6],
|
|
||||||
borderRadius: theme.borderRadius.xl,
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
buttonTextXs: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
formTextInputSm: {
|
|
||||||
...fieldControlSm,
|
|
||||||
...fieldTextSm,
|
|
||||||
},
|
|
||||||
formTextInputMd: {
|
|
||||||
...fieldControlMd,
|
|
||||||
...fieldTextMd,
|
|
||||||
},
|
|
||||||
formTextInput: {
|
|
||||||
...fieldControlMd,
|
|
||||||
...fieldTextMd,
|
|
||||||
},
|
|
||||||
fieldControlSm,
|
|
||||||
fieldControlMd,
|
|
||||||
fieldTextSm,
|
|
||||||
fieldTextMd,
|
|
||||||
controlRest: {
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: "transparent",
|
|
||||||
outlineWidth: 0,
|
|
||||||
outlineColor: "transparent",
|
|
||||||
},
|
|
||||||
controlHover: {
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
controlActive: {
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
outlineColor: theme.colors.accent,
|
|
||||||
outlineOffset: CONTROL_FOCUS_RING_OFFSET,
|
|
||||||
outlineStyle: "solid" as const,
|
|
||||||
outlineWidth: CONTROL_FOCUS_RING_WIDTH,
|
|
||||||
},
|
|
||||||
controlFocusRingColor: {
|
|
||||||
outlineColor: theme.colors.accent,
|
|
||||||
},
|
|
||||||
controlDisabled: {
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
switchControl,
|
|
||||||
segmentedContainerSm: {
|
|
||||||
minHeight: controlHeights.compact,
|
|
||||||
padding: SEGMENTED_COMPACT_INSET,
|
|
||||||
borderRadius: segmentedContainerSmRadius,
|
|
||||||
},
|
|
||||||
segmentedContainerMd: {
|
|
||||||
minHeight: controlHeights.field,
|
|
||||||
padding: SEGMENTED_FIELD_INSET,
|
|
||||||
borderRadius: segmentedContainerMdRadius,
|
|
||||||
},
|
|
||||||
segmentedSegmentSm: {
|
|
||||||
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
|
||||||
},
|
|
||||||
segmentedSegmentMd: {
|
|
||||||
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
|
||||||
paddingHorizontal: theme.spacing[6],
|
|
||||||
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
|
||||||
},
|
|
||||||
segmentedLabelSm: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
segmentedLabelMd: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,9 @@
|
|||||||
import {
|
import { forwardRef, useMemo, type ReactNode } from "react";
|
||||||
forwardRef,
|
import { Text, View, type TextInput } from "react-native";
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ForwardedRef,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
StyleSheet as RNStyleSheet,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
type PressableStateCallbackType,
|
|
||||||
type TextInput,
|
|
||||||
type TextStyle,
|
|
||||||
type ViewStyle,
|
|
||||||
} from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
|
||||||
import {
|
|
||||||
createControlGeometry,
|
|
||||||
resolveControlInteractionStyles,
|
|
||||||
type FieldControlSize,
|
|
||||||
} from "@/components/ui/control-geometry";
|
|
||||||
|
|
||||||
interface FieldProps {
|
interface FormFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
@@ -32,20 +11,20 @@ interface FieldProps {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Field({ label, children, hint, error, testID }: FieldProps) {
|
export function FormField({ label, children, hint, error, testID }: FormFieldProps) {
|
||||||
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
|
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
|
||||||
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
|
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
|
||||||
const subtext = useMemo(() => {
|
const hintOrError = useMemo(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={1} style={styles.errorText} testID={errorTestID}>
|
<Text style={styles.errorText} testID={errorTestID}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hint) {
|
if (hint) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
|
<Text style={styles.hintText} testID={hintTestID}>
|
||||||
{hint}
|
{hint}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -57,167 +36,17 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
|
|||||||
<View style={styles.container} testID={testID}>
|
<View style={styles.container} testID={testID}>
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={styles.label}>{label}</Text>
|
||||||
{children}
|
{children}
|
||||||
{subtext}
|
{hintOrError}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormTextInputProps = AdaptiveTextInputProps & {
|
export const FormTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(function FormTextInput(
|
||||||
size?: FieldControlSize;
|
{ style, ...props },
|
||||||
};
|
|
||||||
|
|
||||||
type FlatFormTextInputStyle = ViewStyle & TextStyle;
|
|
||||||
|
|
||||||
interface SplitFormTextInputStyle {
|
|
||||||
chromeStyle?: ViewStyle;
|
|
||||||
inputStyle?: TextStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitFormTextInputStyle(style: AdaptiveTextInputProps["style"]): SplitFormTextInputStyle {
|
|
||||||
const flattened = RNStyleSheet.flatten(style) as FlatFormTextInputStyle | undefined;
|
|
||||||
if (!flattened) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
color,
|
|
||||||
fontFamily,
|
|
||||||
fontSize,
|
|
||||||
fontStyle,
|
|
||||||
fontVariant,
|
|
||||||
fontWeight,
|
|
||||||
includeFontPadding,
|
|
||||||
letterSpacing,
|
|
||||||
lineHeight,
|
|
||||||
textAlign,
|
|
||||||
textAlignVertical,
|
|
||||||
textDecorationColor,
|
|
||||||
textDecorationLine,
|
|
||||||
textDecorationStyle,
|
|
||||||
textShadowColor,
|
|
||||||
textShadowOffset,
|
|
||||||
textShadowRadius,
|
|
||||||
textTransform,
|
|
||||||
writingDirection,
|
|
||||||
...chromeStyle
|
|
||||||
} = flattened;
|
|
||||||
|
|
||||||
const inputStyle: TextStyle = {
|
|
||||||
color,
|
|
||||||
fontFamily,
|
|
||||||
fontSize,
|
|
||||||
fontStyle,
|
|
||||||
fontVariant,
|
|
||||||
fontWeight,
|
|
||||||
includeFontPadding,
|
|
||||||
letterSpacing,
|
|
||||||
lineHeight,
|
|
||||||
textAlign,
|
|
||||||
textAlignVertical,
|
|
||||||
textDecorationColor,
|
|
||||||
textDecorationLine,
|
|
||||||
textDecorationStyle,
|
|
||||||
textShadowColor,
|
|
||||||
textShadowOffset,
|
|
||||||
textShadowRadius,
|
|
||||||
textTransform,
|
|
||||||
writingDirection,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
chromeStyle: stripUnistylesMetadata(chromeStyle),
|
|
||||||
inputStyle: stripUnistylesMetadata(inputStyle),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripUnistylesMetadata<TStyle extends object>(style: TStyle): TStyle {
|
|
||||||
const cleanStyle: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(style as Record<string, unknown>)) {
|
|
||||||
if (key.startsWith("unistyles_") || value === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cleanStyle[key] = value;
|
|
||||||
}
|
|
||||||
return cleanStyle as TStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInput | null): void {
|
|
||||||
if (typeof forwardedRef === "function") {
|
|
||||||
forwardedRef(node);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (forwardedRef) {
|
|
||||||
forwardedRef.current = node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function FormTextInput(
|
|
||||||
{ size = "md", style, onFocus, onBlur, editable, ...props },
|
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const [focused, setFocused] = useState(false);
|
const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
|
||||||
const isDisabled = editable === false;
|
return <AdaptiveTextInput ref={ref} {...props} style={inputStyle} />;
|
||||||
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
|
|
||||||
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
|
|
||||||
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
|
|
||||||
const setInputRef = useCallback(
|
|
||||||
(node: TextInput | null) => {
|
|
||||||
assignTextInputRef(ref, node);
|
|
||||||
},
|
|
||||||
[ref],
|
|
||||||
);
|
|
||||||
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
|
|
||||||
(event) => {
|
|
||||||
setFocused(true);
|
|
||||||
onFocus?.(event);
|
|
||||||
},
|
|
||||||
[onFocus],
|
|
||||||
);
|
|
||||||
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
|
|
||||||
(event) => {
|
|
||||||
setFocused(false);
|
|
||||||
onBlur?.(event);
|
|
||||||
},
|
|
||||||
[onBlur],
|
|
||||||
);
|
|
||||||
const inputStyle = useMemo(
|
|
||||||
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
|
|
||||||
[inputSizeStyle, splitStyle.inputStyle],
|
|
||||||
) as AdaptiveTextInputProps["style"];
|
|
||||||
const chromeStyle = useCallback(
|
|
||||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
||||||
formInputStyles.chrome,
|
|
||||||
chromeSizeStyle,
|
|
||||||
resolveControlInteractionStyles(
|
|
||||||
{
|
|
||||||
controlRest: formInputStyles.controlRest,
|
|
||||||
controlHover: formInputStyles.controlHover,
|
|
||||||
controlActive: formInputStyles.controlActive,
|
|
||||||
controlDisabled: formInputStyles.controlDisabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hovered,
|
|
||||||
focused,
|
|
||||||
disabled: isDisabled,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
splitStyle.chromeStyle,
|
|
||||||
],
|
|
||||||
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable disabled={isDisabled} style={chromeStyle}>
|
|
||||||
<AdaptiveTextInput
|
|
||||||
ref={setInputRef}
|
|
||||||
editable={editable}
|
|
||||||
{...props}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -227,59 +56,27 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
label: {
|
label: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
hintText: {
|
hintText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
lineHeight: Math.round(theme.fontSize.xs * 1.4),
|
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: theme.colors.palette.red[300],
|
color: theme.colors.palette.red[300],
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
lineHeight: Math.round(theme.fontSize.xs * 1.4),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const formInputStyles = StyleSheet.create((theme) => {
|
const formInputStyles = StyleSheet.create((theme) => ({
|
||||||
const geometry = createControlGeometry(theme);
|
input: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
return {
|
borderRadius: theme.borderRadius.lg,
|
||||||
chrome: {
|
paddingHorizontal: theme.spacing[4],
|
||||||
backgroundColor: theme.colors.surface2,
|
paddingVertical: theme.spacing[3],
|
||||||
},
|
color: theme.colors.foreground,
|
||||||
chromeSm: {
|
borderWidth: 1,
|
||||||
...geometry.fieldControlSm,
|
borderColor: theme.colors.border,
|
||||||
},
|
fontSize: theme.fontSize.base,
|
||||||
chromeMd: {
|
},
|
||||||
...geometry.fieldControlMd,
|
}));
|
||||||
},
|
|
||||||
controlRest: {
|
|
||||||
...geometry.controlRest,
|
|
||||||
},
|
|
||||||
controlHover: {
|
|
||||||
...geometry.controlHover,
|
|
||||||
},
|
|
||||||
controlActive: {
|
|
||||||
...geometry.controlActive,
|
|
||||||
},
|
|
||||||
controlDisabled: {
|
|
||||||
...geometry.controlDisabled,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
paddingVertical: 0,
|
|
||||||
outlineColor: "transparent",
|
|
||||||
outlineWidth: 0,
|
|
||||||
},
|
|
||||||
inputSm: {
|
|
||||||
...geometry.fieldTextSm,
|
|
||||||
},
|
|
||||||
inputMd: {
|
|
||||||
...geometry.fieldTextMd,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useCallback, useMemo, type ReactNode } from "react";
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
|
||||||
createControlGeometry,
|
type SegmentedControlSize = "sm" | "md";
|
||||||
segmentedIconSize,
|
|
||||||
type SegmentedControlSize,
|
|
||||||
} from "@/components/ui/control-geometry";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
|
|
||||||
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
|
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
|
||||||
|
|
||||||
@@ -29,21 +25,6 @@ interface SegmentedControlProps<T extends string> {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SegmentIconProps {
|
|
||||||
icon: SegmentedControlIconRenderer;
|
|
||||||
iconSize: number;
|
|
||||||
iconColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
|
|
||||||
return <View style={styles.iconContainer}>{icon({ color: iconColor, size: iconSize })}</View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
|
||||||
|
|
||||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
|
||||||
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
|
||||||
|
|
||||||
export function SegmentedControl<T extends string>({
|
export function SegmentedControl<T extends string>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -53,10 +34,11 @@ export function SegmentedControl<T extends string>({
|
|||||||
style,
|
style,
|
||||||
testID,
|
testID,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||||
const iconSize = segmentedIconSize[size];
|
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => [styles.container, containerSizeStyle, style],
|
() => [styles.container, containerSizeStyle, style],
|
||||||
@@ -67,12 +49,14 @@ export function SegmentedControl<T extends string>({
|
|||||||
<View style={containerStyle} testID={testID}>
|
<View style={containerStyle} testID={testID}>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = option.value === value;
|
const isSelected = option.value === value;
|
||||||
|
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SegmentItem
|
<SegmentItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
option={option}
|
option={option}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
iconColor={iconColor}
|
||||||
iconSize={iconSize}
|
iconSize={iconSize}
|
||||||
hideLabels={hideLabels}
|
hideLabels={hideLabels}
|
||||||
segmentSizeStyle={segmentSizeStyle}
|
segmentSizeStyle={segmentSizeStyle}
|
||||||
@@ -89,6 +73,7 @@ export function SegmentedControl<T extends string>({
|
|||||||
function SegmentItem<T extends string>({
|
function SegmentItem<T extends string>({
|
||||||
option,
|
option,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
iconColor,
|
||||||
iconSize,
|
iconSize,
|
||||||
hideLabels,
|
hideLabels,
|
||||||
segmentSizeStyle,
|
segmentSizeStyle,
|
||||||
@@ -98,6 +83,7 @@ function SegmentItem<T extends string>({
|
|||||||
}: {
|
}: {
|
||||||
option: SegmentedControlOption<T>;
|
option: SegmentedControlOption<T>;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
iconColor: string;
|
||||||
iconSize: number;
|
iconSize: number;
|
||||||
hideLabels: boolean;
|
hideLabels: boolean;
|
||||||
segmentSizeStyle: StyleProp<ViewStyle>;
|
segmentSizeStyle: StyleProp<ViewStyle>;
|
||||||
@@ -133,18 +119,15 @@ function SegmentItem<T extends string>({
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-selected={isSelected}
|
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
testID={option.testID}
|
testID={option.testID}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
{option.icon ? (
|
{option.icon ? (
|
||||||
<ThemedSegmentIcon
|
<View style={styles.iconContainer}>
|
||||||
icon={option.icon}
|
{option.icon({ color: iconColor, size: iconSize })}
|
||||||
iconSize={iconSize}
|
</View>
|
||||||
uniProps={isSelected ? selectedIconMapping : mutedIconMapping}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
{hideLabels ? null : (
|
{hideLabels ? null : (
|
||||||
<Text style={labelStyle} numberOfLines={1}>
|
<Text style={labelStyle} numberOfLines={1}>
|
||||||
@@ -155,68 +138,68 @@ function SegmentItem<T extends string>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => {
|
const styles = StyleSheet.create((theme) => ({
|
||||||
const geometry = createControlGeometry(theme);
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
return {
|
alignItems: "stretch",
|
||||||
container: {
|
backgroundColor: theme.colors.surface2,
|
||||||
flexDirection: "row",
|
borderRadius: theme.borderRadius.lg,
|
||||||
alignItems: "stretch",
|
gap: 2,
|
||||||
backgroundColor: theme.colors.surface2,
|
},
|
||||||
gap: 2,
|
containerSm: {
|
||||||
},
|
padding: 2,
|
||||||
containerSm: {
|
},
|
||||||
...geometry.segmentedContainerSm,
|
containerMd: {
|
||||||
},
|
padding: 3,
|
||||||
containerMd: {
|
},
|
||||||
...geometry.segmentedContainerMd,
|
segment: {
|
||||||
},
|
flexDirection: "row",
|
||||||
segment: {
|
alignItems: "center",
|
||||||
flexDirection: "row",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
flexShrink: 0,
|
||||||
justifyContent: "center",
|
borderRadius: theme.borderRadius.lg,
|
||||||
flexShrink: 0,
|
gap: theme.spacing[1],
|
||||||
gap: theme.spacing[1],
|
},
|
||||||
},
|
segmentSm: {
|
||||||
segmentSm: {
|
paddingVertical: theme.spacing[1.5],
|
||||||
...geometry.segmentedSegmentSm,
|
paddingHorizontal: theme.spacing[4],
|
||||||
},
|
},
|
||||||
segmentMd: {
|
segmentMd: {
|
||||||
...geometry.segmentedSegmentMd,
|
paddingVertical: theme.spacing[3],
|
||||||
},
|
paddingHorizontal: theme.spacing[6],
|
||||||
segmentSelected: {
|
},
|
||||||
backgroundColor: theme.colors.surface0,
|
segmentSelected: {
|
||||||
shadowColor: "#000",
|
backgroundColor: theme.colors.surface0,
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowColor: "#000",
|
||||||
shadowOpacity: 0.08,
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowRadius: 2,
|
shadowOpacity: 0.08,
|
||||||
elevation: 1,
|
shadowRadius: 2,
|
||||||
},
|
elevation: 1,
|
||||||
segmentHover: {
|
},
|
||||||
backgroundColor: theme.colors.surface1,
|
segmentHover: {
|
||||||
},
|
backgroundColor: theme.colors.surface1,
|
||||||
segmentPressed: {
|
},
|
||||||
backgroundColor: theme.colors.surface1,
|
segmentPressed: {
|
||||||
},
|
backgroundColor: theme.colors.surface1,
|
||||||
segmentDisabled: {
|
},
|
||||||
opacity: theme.opacity[50],
|
segmentDisabled: {
|
||||||
},
|
opacity: theme.opacity[50],
|
||||||
iconContainer: {
|
},
|
||||||
alignItems: "center",
|
iconContainer: {
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
},
|
justifyContent: "center",
|
||||||
label: {
|
},
|
||||||
color: theme.colors.foregroundMuted,
|
label: {
|
||||||
fontWeight: theme.fontWeight.normal,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
fontWeight: theme.fontWeight.normal,
|
||||||
labelSm: {
|
},
|
||||||
...geometry.segmentedLabelSm,
|
labelSm: {
|
||||||
},
|
fontSize: theme.fontSize.sm,
|
||||||
labelMd: {
|
},
|
||||||
...geometry.segmentedLabelMd,
|
labelMd: {
|
||||||
},
|
fontSize: theme.fontSize.base,
|
||||||
labelSelected: {
|
},
|
||||||
color: theme.colors.foreground,
|
labelSelected: {
|
||||||
},
|
color: theme.colors.foreground,
|
||||||
};
|
},
|
||||||
});
|
}));
|
||||||
|
|||||||
@@ -1,394 +0,0 @@
|
|||||||
import { useCallback, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
type NativeSyntheticEvent,
|
|
||||||
type PressableStateCallbackType,
|
|
||||||
type TargetedEvent,
|
|
||||||
} from "react-native";
|
|
||||||
import { ChevronDown } from "lucide-react-native";
|
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
|
||||||
import {
|
|
||||||
createControlGeometry,
|
|
||||||
resolveControlInteractionStyles,
|
|
||||||
type FieldControlSize,
|
|
||||||
} from "@/components/ui/control-geometry";
|
|
||||||
import { Field } from "@/components/ui/form-field";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
||||||
|
|
||||||
export interface SelectFieldDisplay {
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectFieldOption<TValue> {
|
|
||||||
id: string;
|
|
||||||
value: TValue;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
kind?: ComboboxOption["kind"];
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectFieldRenderOptionInput<TValue> {
|
|
||||||
option: SelectFieldOption<TValue>;
|
|
||||||
selected: boolean;
|
|
||||||
active: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectFieldProps<TValue> {
|
|
||||||
label: string;
|
|
||||||
value: TValue | null;
|
|
||||||
selectedDisplay: SelectFieldDisplay | null;
|
|
||||||
options: SelectFieldOption<TValue>[];
|
|
||||||
onChange: (value: TValue, display: SelectFieldDisplay) => void;
|
|
||||||
placeholder: string;
|
|
||||||
emptyText: string;
|
|
||||||
loading?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
hint?: string;
|
|
||||||
error?: string | null;
|
|
||||||
searchable?: boolean;
|
|
||||||
searchPlaceholder?: string;
|
|
||||||
title?: string;
|
|
||||||
size?: FieldControlSize;
|
|
||||||
getValueKey?: (value: TValue) => string;
|
|
||||||
renderOption?: (input: SelectFieldRenderOptionInput<TValue>) => ReactElement;
|
|
||||||
triggerLeading?: ReactNode;
|
|
||||||
field?: boolean;
|
|
||||||
testID?: string;
|
|
||||||
triggerTestID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectFieldTriggerProps {
|
|
||||||
display?: SelectFieldDisplay | null;
|
|
||||||
label?: string;
|
|
||||||
isPlaceholder?: boolean;
|
|
||||||
placeholder: string;
|
|
||||||
hovered?: boolean;
|
|
||||||
focused?: boolean;
|
|
||||||
active?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
leading?: ReactNode;
|
|
||||||
size?: FieldControlSize;
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
||||||
|
|
||||||
const foregroundMutedMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
});
|
|
||||||
|
|
||||||
function getSelectedOptionId<TValue>(
|
|
||||||
options: readonly SelectFieldOption<TValue>[],
|
|
||||||
value: TValue | null,
|
|
||||||
getValueKey: ((value: TValue) => string) | undefined,
|
|
||||||
): string {
|
|
||||||
if (value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (getValueKey) {
|
|
||||||
const selectedKey = getValueKey(value);
|
|
||||||
return options.find((option) => getValueKey(option.value) === selectedKey)?.id ?? "";
|
|
||||||
}
|
|
||||||
return options.find((option) => Object.is(option.value, value))?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function useVisibleSelectOptions<TValue>(
|
|
||||||
options: SelectFieldOption<TValue>[],
|
|
||||||
loading: boolean,
|
|
||||||
): SelectFieldOption<TValue>[] {
|
|
||||||
const previousOptionsRef = useRef<SelectFieldOption<TValue>[]>(options);
|
|
||||||
if (options.length > 0 || !loading) {
|
|
||||||
previousOptionsRef.current = options;
|
|
||||||
}
|
|
||||||
if (loading && options.length === 0) {
|
|
||||||
return previousOptionsRef.current;
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectFieldTrigger({
|
|
||||||
display,
|
|
||||||
label: explicitLabel,
|
|
||||||
isPlaceholder: explicitIsPlaceholder,
|
|
||||||
placeholder,
|
|
||||||
hovered = false,
|
|
||||||
focused = false,
|
|
||||||
active = false,
|
|
||||||
disabled = false,
|
|
||||||
loading = false,
|
|
||||||
leading,
|
|
||||||
size = "md",
|
|
||||||
testID,
|
|
||||||
}: SelectFieldTriggerProps): ReactElement {
|
|
||||||
const sizeStyle = size === "sm" ? styles.triggerSm : styles.triggerMd;
|
|
||||||
const textSizeStyle = size === "sm" ? styles.triggerTextSm : styles.triggerTextMd;
|
|
||||||
const triggerStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.trigger,
|
|
||||||
sizeStyle,
|
|
||||||
resolveControlInteractionStyles(
|
|
||||||
{
|
|
||||||
controlRest: styles.controlRest,
|
|
||||||
controlHover: styles.controlHover,
|
|
||||||
controlActive: styles.controlActive,
|
|
||||||
controlDisabled: styles.controlDisabled,
|
|
||||||
},
|
|
||||||
{ hovered, focused, active, disabled },
|
|
||||||
),
|
|
||||||
],
|
|
||||||
[active, disabled, focused, hovered, sizeStyle],
|
|
||||||
);
|
|
||||||
const label = explicitLabel ?? display?.label ?? placeholder;
|
|
||||||
const isPlaceholder = explicitIsPlaceholder ?? display == null;
|
|
||||||
const textStyle = useMemo(
|
|
||||||
() => [isPlaceholder ? styles.placeholderText : styles.triggerText, textSizeStyle],
|
|
||||||
[isPlaceholder, textSizeStyle],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View pointerEvents="none" style={triggerStyle} testID={testID}>
|
|
||||||
{leading}
|
|
||||||
<Text style={textStyle} numberOfLines={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
{loading ? (
|
|
||||||
<View style={styles.spinnerSlot}>
|
|
||||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<ThemedChevronDown size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectField<TValue>({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
selectedDisplay,
|
|
||||||
options,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
emptyText,
|
|
||||||
loading = false,
|
|
||||||
disabled = false,
|
|
||||||
hint,
|
|
||||||
error,
|
|
||||||
searchable = false,
|
|
||||||
searchPlaceholder,
|
|
||||||
title,
|
|
||||||
size = "md",
|
|
||||||
getValueKey,
|
|
||||||
renderOption,
|
|
||||||
triggerLeading,
|
|
||||||
field = true,
|
|
||||||
testID,
|
|
||||||
triggerTestID,
|
|
||||||
}: SelectFieldProps<TValue>): ReactElement {
|
|
||||||
const anchorRef = useRef<View>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [triggerFocused, setTriggerFocused] = useState(false);
|
|
||||||
const visibleOptions = useVisibleSelectOptions(options, loading);
|
|
||||||
const selectedOptionId = useMemo(
|
|
||||||
() => getSelectedOptionId(visibleOptions, value, getValueKey),
|
|
||||||
[getValueKey, value, visibleOptions],
|
|
||||||
);
|
|
||||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
|
||||||
() =>
|
|
||||||
visibleOptions.map((option) => ({
|
|
||||||
id: option.id,
|
|
||||||
label: option.label,
|
|
||||||
description: option.description,
|
|
||||||
kind: option.kind,
|
|
||||||
})),
|
|
||||||
[visibleOptions],
|
|
||||||
);
|
|
||||||
const optionById = useMemo(
|
|
||||||
() => new Map(visibleOptions.map((option) => [option.id, option])),
|
|
||||||
[visibleOptions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
const option = optionById.get(id);
|
|
||||||
if (!option) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onChange(option.value, { label: option.label, description: option.description });
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
[onChange, optionById],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
if (disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpen((current) => !current);
|
|
||||||
}, [disabled]);
|
|
||||||
const handleTriggerFocus = useCallback((_event: NativeSyntheticEvent<TargetedEvent>) => {
|
|
||||||
setTriggerFocused(true);
|
|
||||||
}, []);
|
|
||||||
const handleTriggerBlur = useCallback((_event: NativeSyntheticEvent<TargetedEvent>) => {
|
|
||||||
setTriggerFocused(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderComboboxOption = useCallback(
|
|
||||||
({
|
|
||||||
option,
|
|
||||||
selected,
|
|
||||||
active,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
option: ComboboxOption;
|
|
||||||
selected: boolean;
|
|
||||||
active: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) => {
|
|
||||||
const selectOption = optionById.get(option.id);
|
|
||||||
if (!selectOption) {
|
|
||||||
return (
|
|
||||||
<ComboboxItem
|
|
||||||
label={option.label}
|
|
||||||
description={option.description}
|
|
||||||
kind={option.kind}
|
|
||||||
selected={selected}
|
|
||||||
active={active}
|
|
||||||
onPress={onPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (renderOption) {
|
|
||||||
return renderOption({ option: selectOption, selected, active, onPress });
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ComboboxItem
|
|
||||||
testID={selectOption.testID}
|
|
||||||
label={selectOption.label}
|
|
||||||
description={selectOption.description}
|
|
||||||
kind={selectOption.kind}
|
|
||||||
selected={selected}
|
|
||||||
active={active}
|
|
||||||
onPress={onPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[optionById, renderOption],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayLabel = selectedDisplay?.label ?? placeholder;
|
|
||||||
const fieldHint = selectedDisplay?.description ?? hint;
|
|
||||||
|
|
||||||
const control = (
|
|
||||||
<>
|
|
||||||
<View ref={anchorRef} collapsable={false}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handlePress}
|
|
||||||
onFocus={handleTriggerFocus}
|
|
||||||
onBlur={handleTriggerBlur}
|
|
||||||
disabled={disabled}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`${label} (${displayLabel})`}
|
|
||||||
testID={triggerTestID}
|
|
||||||
>
|
|
||||||
{({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => (
|
|
||||||
<SelectFieldTrigger
|
|
||||||
display={selectedDisplay}
|
|
||||||
placeholder={placeholder}
|
|
||||||
hovered={Boolean(hovered)}
|
|
||||||
focused={triggerFocused}
|
|
||||||
active={pressed || open}
|
|
||||||
disabled={disabled}
|
|
||||||
loading={loading}
|
|
||||||
leading={triggerLeading}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<Combobox
|
|
||||||
options={comboboxOptions}
|
|
||||||
value={selectedOptionId}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
searchable={searchable}
|
|
||||||
searchPlaceholder={searchPlaceholder}
|
|
||||||
emptyText={loading && visibleOptions.length === 0 ? "Loading..." : emptyText}
|
|
||||||
title={title ?? label}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
anchorRef={anchorRef}
|
|
||||||
renderOption={renderComboboxOption}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!field) {
|
|
||||||
return <View testID={testID}>{control}</View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field label={label} hint={fieldHint} error={error} testID={testID}>
|
|
||||||
{control}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => {
|
|
||||||
const geometry = createControlGeometry(theme);
|
|
||||||
|
|
||||||
return {
|
|
||||||
trigger: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
triggerSm: {
|
|
||||||
...geometry.fieldControlSm,
|
|
||||||
},
|
|
||||||
triggerMd: {
|
|
||||||
...geometry.fieldControlMd,
|
|
||||||
},
|
|
||||||
controlRest: {
|
|
||||||
...geometry.controlRest,
|
|
||||||
},
|
|
||||||
controlHover: {
|
|
||||||
...geometry.controlHover,
|
|
||||||
},
|
|
||||||
controlActive: {
|
|
||||||
...geometry.controlActive,
|
|
||||||
},
|
|
||||||
controlDisabled: {
|
|
||||||
...geometry.controlDisabled,
|
|
||||||
},
|
|
||||||
triggerText: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
placeholderText: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
triggerTextSm: {
|
|
||||||
...geometry.fieldTextSm,
|
|
||||||
},
|
|
||||||
triggerTextMd: {
|
|
||||||
...geometry.fieldTextMd,
|
|
||||||
},
|
|
||||||
spinnerSlot: {
|
|
||||||
flexShrink: 0,
|
|
||||||
width: ICON_SIZE.md,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -7,15 +7,10 @@ import { Switch } from "./switch";
|
|||||||
const { theme } = vi.hoisted(() => ({
|
const { theme } = vi.hoisted(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
opacity: { 50: 0.5 },
|
opacity: { 50: 0.5 },
|
||||||
spacing: { 0: 0, 3: 12, 4: 16, 6: 24 },
|
|
||||||
fontSize: { xs: 12, sm: 14, base: 16 },
|
|
||||||
borderRadius: { md: 6, lg: 8, xl: 12 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
colors: {
|
colors: {
|
||||||
surface3: "#333",
|
surface3: "#333",
|
||||||
accent: "#0a84ff",
|
accent: "#0a84ff",
|
||||||
accentForeground: "#fff",
|
accentForeground: "#fff",
|
||||||
borderAccent: "#555",
|
|
||||||
palette: { white: "#fff" },
|
palette: { white: "#fff" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -37,9 +32,6 @@ vi.mock("react-native-reanimated", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
vi.mock("react-native", () => ({
|
||||||
Platform: {
|
|
||||||
select: (values: Record<string, unknown>) => values.web ?? values.default,
|
|
||||||
},
|
|
||||||
Pressable: ({
|
Pressable: ({
|
||||||
"aria-checked": ariaChecked,
|
"aria-checked": ariaChecked,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
@@ -79,13 +71,7 @@ vi.mock("react-native-unistyles", () => ({
|
|||||||
StyleSheet: {
|
StyleSheet: {
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
},
|
},
|
||||||
withUnistyles:
|
useUnistyles: () => ({ theme }),
|
||||||
(
|
|
||||||
Component: React.ComponentType<Record<string, unknown>>,
|
|
||||||
mapping?: (theme: unknown) => object,
|
|
||||||
) =>
|
|
||||||
(props: Record<string, unknown>) =>
|
|
||||||
React.createElement(Component, { ...props, ...mapping?.(theme) }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Switch", () => {
|
describe("Switch", () => {
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import Animated, {
|
|||||||
useDerivedValue,
|
useDerivedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { createControlGeometry, switchGeometry } from "@/components/ui/control-geometry";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
|
|
||||||
interface SwitchProps {
|
interface SwitchProps {
|
||||||
value: boolean;
|
value: boolean;
|
||||||
@@ -25,54 +23,11 @@ interface SwitchProps {
|
|||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRACK = { width: 34, height: 20 };
|
||||||
|
const THUMB = 16;
|
||||||
|
|
||||||
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
|
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
|
||||||
|
|
||||||
interface SwitchTrackProps {
|
|
||||||
value: boolean;
|
|
||||||
trackOffColor: string;
|
|
||||||
trackOnColor: string;
|
|
||||||
thumbOffColor: string;
|
|
||||||
thumbOnColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SwitchTrack({
|
|
||||||
value,
|
|
||||||
trackOffColor,
|
|
||||||
trackOnColor,
|
|
||||||
thumbOffColor,
|
|
||||||
thumbOnColor,
|
|
||||||
}: SwitchTrackProps) {
|
|
||||||
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
|
|
||||||
|
|
||||||
const trackAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
backgroundColor: interpolateColor(progress.value, [0, 1], [trackOffColor, trackOnColor]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
backgroundColor: interpolateColor(progress.value, [0, 1], [thumbOffColor, thumbOnColor]),
|
|
||||||
transform: [{ translateX: progress.value * switchGeometry.thumbTravel }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const trackStyle = useMemo(() => [styles.switchTrack, trackAnimatedStyle], [trackAnimatedStyle]);
|
|
||||||
const thumbStyle = useMemo(
|
|
||||||
() => [styles.switchThumb, styles.thumb, thumbAnimatedStyle],
|
|
||||||
[thumbAnimatedStyle],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={trackStyle}>
|
|
||||||
<Animated.View style={thumbStyle} />
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemedSwitchTrack = withUnistyles(SwitchTrack, (theme: Theme) => ({
|
|
||||||
trackOffColor: theme.colors.surface3,
|
|
||||||
trackOnColor: theme.colors.accent,
|
|
||||||
thumbOffColor: theme.colors.palette.white,
|
|
||||||
thumbOnColor: theme.colors.accentForeground,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function Switch({
|
export function Switch({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
@@ -81,6 +36,31 @@ export function Switch({
|
|||||||
testID,
|
testID,
|
||||||
style,
|
style,
|
||||||
}: SwitchProps) {
|
}: SwitchProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const track = TRACK;
|
||||||
|
const thumb = THUMB;
|
||||||
|
const padding = (track.height - thumb) / 2;
|
||||||
|
const thumbTravel = track.width - thumb - padding * 2;
|
||||||
|
|
||||||
|
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
|
||||||
|
|
||||||
|
const trackAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
[theme.colors.surface3, theme.colors.accent],
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
[theme.colors.palette.white, theme.colors.accentForeground],
|
||||||
|
),
|
||||||
|
transform: [{ translateX: progress.value * thumbTravel }],
|
||||||
|
}));
|
||||||
|
|
||||||
const handlePress = useCallback(
|
const handlePress = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -92,9 +72,30 @@ export function Switch({
|
|||||||
|
|
||||||
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
|
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
|
||||||
const pressableStyle = useMemo(
|
const pressableStyle = useMemo(
|
||||||
() => [styles.switchControl, disabled ? styles.disabled : null, style],
|
() => [disabled ? styles.disabled : null, style],
|
||||||
[disabled, style],
|
[disabled, style],
|
||||||
);
|
);
|
||||||
|
const trackStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.track,
|
||||||
|
{
|
||||||
|
width: track.width,
|
||||||
|
height: track.height,
|
||||||
|
borderRadius: track.height / 2,
|
||||||
|
padding,
|
||||||
|
},
|
||||||
|
trackAnimatedStyle,
|
||||||
|
],
|
||||||
|
[track.width, track.height, padding, trackAnimatedStyle],
|
||||||
|
);
|
||||||
|
const thumbStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.thumb,
|
||||||
|
{ width: thumb, height: thumb, borderRadius: thumb / 2 },
|
||||||
|
thumbAnimatedStyle,
|
||||||
|
],
|
||||||
|
[thumb, thumbAnimatedStyle],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -108,39 +109,25 @@ export function Switch({
|
|||||||
testID={testID}
|
testID={testID}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
<ThemedSwitchTrack value={value} />
|
<Animated.View style={trackStyle}>
|
||||||
|
<Animated.View style={thumbStyle} />
|
||||||
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => {
|
const styles = StyleSheet.create((theme) => ({
|
||||||
const geometry = createControlGeometry(theme);
|
track: {
|
||||||
|
justifyContent: "center",
|
||||||
return {
|
},
|
||||||
switchControl: {
|
thumb: {
|
||||||
...geometry.switchControl,
|
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||||
},
|
shadowOffset: { width: 0, height: 1 },
|
||||||
switchTrack: {
|
shadowRadius: 2,
|
||||||
width: switchGeometry.trackWidth,
|
shadowOpacity: 1,
|
||||||
height: switchGeometry.trackHeight,
|
elevation: 2,
|
||||||
borderRadius: switchGeometry.trackHeight / 2,
|
},
|
||||||
padding: (switchGeometry.trackHeight - switchGeometry.thumbSize) / 2,
|
disabled: {
|
||||||
justifyContent: "center",
|
opacity: theme.opacity[50],
|
||||||
},
|
},
|
||||||
switchThumb: {
|
}));
|
||||||
width: switchGeometry.thumbSize,
|
|
||||||
height: switchGeometry.thumbSize,
|
|
||||||
borderRadius: switchGeometry.thumbSize / 2,
|
|
||||||
},
|
|
||||||
thumb: {
|
|
||||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowRadius: 2,
|
|
||||||
shadowOpacity: 1,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -805,8 +805,6 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
|||||||
onRetryProvider={onRetryModelProvider}
|
onRetryProvider={onRetryModelProvider}
|
||||||
isRetryingProvider={isRetryingModelProvider}
|
isRetryingProvider={isRetryingModelProvider}
|
||||||
serverId={modelSelectorServerId}
|
serverId={modelSelectorServerId}
|
||||||
desktopPlacement="top-start"
|
|
||||||
desktopMinWidth={360}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -1014,8 +1012,6 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
isRetryingProvider={isRetryingModelProvider}
|
isRetryingProvider={isRetryingModelProvider}
|
||||||
renderTrigger={renderModelTrigger}
|
renderTrigger={renderModelTrigger}
|
||||||
serverId={modelSelectorServerId}
|
serverId={modelSelectorServerId}
|
||||||
desktopPlacement="top-start"
|
|
||||||
desktopMinWidth={360}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1688,8 +1684,6 @@ export function DraftAgentControls({
|
|||||||
onRetryProvider={onRetryModelProvider}
|
onRetryProvider={onRetryModelProvider}
|
||||||
isRetryingProvider={isRetryingModelProvider}
|
isRetryingProvider={isRetryingModelProvider}
|
||||||
serverId={modelSelectorServerId}
|
serverId={modelSelectorServerId}
|
||||||
desktopPlacement="top-start"
|
|
||||||
desktopMinWidth={360}
|
|
||||||
/>
|
/>
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<ControlledAgentControls
|
<ControlledAgentControls
|
||||||
|
|||||||
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
|
|||||||
title: "Cline",
|
title: "Cline",
|
||||||
description:
|
description:
|
||||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||||
version: "3.0.38",
|
version: "3.0.37",
|
||||||
iconId: "cline",
|
iconId: "cline",
|
||||||
installLink: "https://cline.bot/cli",
|
installLink: "https://cline.bot/cli",
|
||||||
command: ["npx", "-y", "cline@3.0.38", "--acp"],
|
command: ["npx", "-y", "cline@3.0.37", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codebuddy-code",
|
id: "codebuddy-code",
|
||||||
title: "Codebuddy Code",
|
title: "Codebuddy Code",
|
||||||
description: "Tencent Cloud's official intelligent coding tool",
|
description: "Tencent Cloud's official intelligent coding tool",
|
||||||
version: "2.117.2",
|
version: "2.116.0",
|
||||||
iconId: "codebuddy-code",
|
iconId: "codebuddy-code",
|
||||||
installLink: "https://www.codebuddy.cn/cli/",
|
installLink: "https://www.codebuddy.cn/cli/",
|
||||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.117.2", "--acp"],
|
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.116.0", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codewhale",
|
id: "codewhale",
|
||||||
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
|
|||||||
id: "dimcode",
|
id: "dimcode",
|
||||||
title: "DimCode",
|
title: "DimCode",
|
||||||
description: "A coding agent that puts leading models at your command.",
|
description: "A coding agent that puts leading models at your command.",
|
||||||
version: "0.2.22",
|
version: "0.2.19",
|
||||||
iconId: "dimcode",
|
iconId: "dimcode",
|
||||||
installLink: "https://dimcode.dev/docs/acp.html",
|
installLink: "https://dimcode.dev/docs/acp.html",
|
||||||
command: ["npx", "-y", "dimcode@0.2.22", "acp"],
|
command: ["npx", "-y", "dimcode@0.2.19", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dirac",
|
id: "dirac",
|
||||||
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
|
|||||||
id: "factory-droid",
|
id: "factory-droid",
|
||||||
title: "Factory Droid",
|
title: "Factory Droid",
|
||||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||||
version: "0.164.1",
|
version: "0.164.0",
|
||||||
iconId: "factory-droid",
|
iconId: "factory-droid",
|
||||||
installLink: "https://factory.ai/product/cli",
|
installLink: "https://factory.ai/product/cli",
|
||||||
command: ["npx", "-y", "droid@0.164.1", "exec", "--output-format", "acp-daemon"],
|
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
|
||||||
env: {
|
env: {
|
||||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.9.2",
|
version: "0.8.3",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.9.2", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
|||||||
id: "nova",
|
id: "nova",
|
||||||
title: "Nova",
|
title: "Nova",
|
||||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||||
version: "1.1.25",
|
version: "1.1.24",
|
||||||
iconId: "nova",
|
iconId: "nova",
|
||||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||||
command: ["npx", "-y", "@compass-ai/nova@1.1.25", "acp"],
|
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "poolside",
|
id: "poolside",
|
||||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
|||||||
id: "qoder",
|
id: "qoder",
|
||||||
title: "Qoder CLI",
|
title: "Qoder CLI",
|
||||||
description: "AI coding assistant with agentic capabilities",
|
description: "AI coding assistant with agentic capabilities",
|
||||||
version: "1.0.39",
|
version: "1.0.37",
|
||||||
iconId: "qoder",
|
iconId: "qoder",
|
||||||
installLink: "https://qoder.com",
|
installLink: "https://qoder.com",
|
||||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.39", "--acp"],
|
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.37", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qwen-code",
|
id: "qwen-code",
|
||||||
title: "Qwen Code",
|
title: "Qwen Code",
|
||||||
description: "Alibaba's Qwen coding assistant",
|
description: "Alibaba's Qwen coding assistant",
|
||||||
version: "0.19.7",
|
version: "0.19.6",
|
||||||
iconId: "qwen-code",
|
iconId: "qwen-code",
|
||||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.7", "--acp", "--experimental-skills"],
|
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.6", "--acp", "--experimental-skills"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sigit",
|
id: "sigit",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export function daemonConfigQueryKey(serverId: string | null) {
|
|
||||||
return ["daemon-config", serverId] as const;
|
|
||||||
}
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
||||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
|
||||||
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
|
|
||||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
|
||||||
import { providersSnapshotQueryKey } from "@/data/providers-snapshot";
|
|
||||||
import {
|
|
||||||
checkoutDiffPushRoute,
|
|
||||||
invalidateServerDataQueriesAfterReconnect,
|
|
||||||
mountServerDataPushRouter,
|
|
||||||
workspaceTerminalsPushRoute,
|
|
||||||
} from "@/data/push-router";
|
|
||||||
|
|
||||||
type ProvidersSnapshotUpdateMessage = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "providers_snapshot_update" }
|
|
||||||
>;
|
|
||||||
type CheckoutDiffUpdateMessage = Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>;
|
|
||||||
type SubscribeCheckoutDiffResponseMessage = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "subscribe_checkout_diff_response" }
|
|
||||||
>;
|
|
||||||
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
|
||||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
|
||||||
type RouterMessage =
|
|
||||||
| ProvidersSnapshotUpdateMessage
|
|
||||||
| CheckoutDiffUpdateMessage
|
|
||||||
| SubscribeCheckoutDiffResponseMessage
|
|
||||||
| StatusMessage
|
|
||||||
| TerminalsChangedMessage;
|
|
||||||
type RouterMessageType = RouterMessage["type"];
|
|
||||||
type RouterHandler = (message: RouterMessage) => void;
|
|
||||||
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
|
|
||||||
|
|
||||||
const daemonConfig: MutableDaemonConfig = {
|
|
||||||
mcp: { injectIntoAgents: true },
|
|
||||||
browserTools: { enabled: false },
|
|
||||||
providers: {},
|
|
||||||
metadataGeneration: { providers: [] },
|
|
||||||
autoArchiveAfterMerge: false,
|
|
||||||
enableTerminalAgentHooks: false,
|
|
||||||
appendSystemPrompt: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}): {
|
|
||||||
client: RouterClient;
|
|
||||||
emit: <K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>) => void;
|
|
||||||
subscribeCheckoutDiffCalls: Array<{
|
|
||||||
cwd: string;
|
|
||||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
|
||||||
subscriptionId: string;
|
|
||||||
}>;
|
|
||||||
unsubscribeCheckoutDiffCalls: string[];
|
|
||||||
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
|
||||||
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
|
||||||
} {
|
|
||||||
const handlers: Record<RouterMessageType, RouterHandler[]> = {
|
|
||||||
providers_snapshot_update: [],
|
|
||||||
checkout_diff_update: [],
|
|
||||||
subscribe_checkout_diff_response: [],
|
|
||||||
status: [],
|
|
||||||
terminals_changed: [],
|
|
||||||
};
|
|
||||||
const subscribeCheckoutDiffCalls: Array<{
|
|
||||||
cwd: string;
|
|
||||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
|
||||||
subscriptionId: string;
|
|
||||||
}> = [];
|
|
||||||
const unsubscribeCheckoutDiffCalls: string[] = [];
|
|
||||||
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
|
||||||
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
|
|
||||||
|
|
||||||
function on<K extends RouterMessageType>(
|
|
||||||
type: K,
|
|
||||||
handler: (message: Extract<RouterMessage, { type: K }>) => void,
|
|
||||||
): () => void {
|
|
||||||
const routerHandler: RouterHandler = (message) => {
|
|
||||||
if (message.type === type) {
|
|
||||||
handler(message as Extract<RouterMessage, { type: K }>);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handlers[type].push(routerHandler);
|
|
||||||
return () => {
|
|
||||||
handlers[type] = handlers[type].filter((candidate) => candidate !== routerHandler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emit<K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>): void {
|
|
||||||
for (const handler of handlers[message.type]) {
|
|
||||||
handler(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
client: {
|
|
||||||
on,
|
|
||||||
async subscribeCheckoutDiff(cwd, compare, requestOptions) {
|
|
||||||
subscribeCheckoutDiffCalls.push({
|
|
||||||
cwd,
|
|
||||||
compare,
|
|
||||||
subscriptionId: requestOptions.subscriptionId,
|
|
||||||
});
|
|
||||||
if (config.rejectCheckoutDiffSubscribe) {
|
|
||||||
throw new Error("subscribe failed");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
subscriptionId: requestOptions.subscriptionId,
|
|
||||||
cwd,
|
|
||||||
files: [],
|
|
||||||
error: null,
|
|
||||||
requestId: requestOptions.requestId ?? "subscribe-checkout-diff",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
unsubscribeCheckoutDiff(subscriptionId) {
|
|
||||||
unsubscribeCheckoutDiffCalls.push(subscriptionId);
|
|
||||||
},
|
|
||||||
subscribeTerminals(subscription) {
|
|
||||||
subscribeTerminalCalls.push(subscription);
|
|
||||||
},
|
|
||||||
unsubscribeTerminals(subscription) {
|
|
||||||
unsubscribeTerminalCalls.push(subscription);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emit,
|
|
||||||
subscribeCheckoutDiffCalls,
|
|
||||||
unsubscribeCheckoutDiffCalls,
|
|
||||||
subscribeTerminalCalls,
|
|
||||||
unsubscribeTerminalCalls,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
|
||||||
return {
|
|
||||||
type: "providers_snapshot_update",
|
|
||||||
payload: {
|
|
||||||
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
|
|
||||||
generatedAt,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("server data push router", () => {
|
|
||||||
it("routes provider snapshot and daemon config payloads until detached", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const fake = createFakeClient();
|
|
||||||
const serverId = "server-1";
|
|
||||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
|
||||||
|
|
||||||
fake.emit(providerUpdate("2026-01-01T00:00:00.000Z"));
|
|
||||||
fake.emit({
|
|
||||||
type: "status",
|
|
||||||
payload: { status: "daemon_config_changed", config: daemonConfig },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({
|
|
||||||
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
|
|
||||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
requestId: "providers_snapshot_update",
|
|
||||||
});
|
|
||||||
expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
fake.emit(providerUpdate("2026-01-01T00:00:01.000Z"));
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({
|
|
||||||
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
|
|
||||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
requestId: "providers_snapshot_update",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subscribes active checkout diff queries and writes matching diff events", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const fake = createFakeClient();
|
|
||||||
const serverId = "server-1";
|
|
||||||
const cwd = "/repo";
|
|
||||||
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
|
|
||||||
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
|
|
||||||
const observer = new QueryObserver(queryClient, {
|
|
||||||
queryKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: checkoutDiffPushRoute({
|
|
||||||
enabled: true,
|
|
||||||
serverId,
|
|
||||||
subscriptionId,
|
|
||||||
cwd,
|
|
||||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const unsubscribeObserver = observer.subscribe(() => undefined);
|
|
||||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
|
||||||
|
|
||||||
expect(fake.subscribeCheckoutDiffCalls).toEqual([
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
|
||||||
subscriptionId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
fake.emit({
|
|
||||||
type: "subscribe_checkout_diff_response",
|
|
||||||
payload: { subscriptionId, cwd, files: [], error: null, requestId: "diff-1" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(queryKey)).toEqual({
|
|
||||||
cwd,
|
|
||||||
files: [],
|
|
||||||
error: null,
|
|
||||||
requestId: "diff-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
fake.emit({
|
|
||||||
type: "checkout_diff_update",
|
|
||||||
payload: { subscriptionId, cwd, files: [], error: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(queryKey)).toEqual({
|
|
||||||
cwd,
|
|
||||||
files: [],
|
|
||||||
error: null,
|
|
||||||
requestId: `subscription:${subscriptionId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribeObserver();
|
|
||||||
|
|
||||||
expect(fake.unsubscribeCheckoutDiffCalls).toEqual([subscriptionId]);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not retry failed subscriptions on unrelated cache events", async () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true });
|
|
||||||
const serverId = "server-1";
|
|
||||||
const cwd = "/repo";
|
|
||||||
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
|
|
||||||
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
|
|
||||||
const observer = new QueryObserver(queryClient, {
|
|
||||||
queryKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: checkoutDiffPushRoute({
|
|
||||||
enabled: true,
|
|
||||||
serverId,
|
|
||||||
subscriptionId,
|
|
||||||
cwd,
|
|
||||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const unsubscribeObserver = observer.subscribe(() => undefined);
|
|
||||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
|
||||||
|
|
||||||
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
queryClient.setQueryData(["unrelated"], "value");
|
|
||||||
|
|
||||||
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
|
|
||||||
|
|
||||||
unsubscribeObserver();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subscribes active terminal queries and filters terminal pushes by workspace", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const fake = createFakeClient();
|
|
||||||
const serverId = "server-1";
|
|
||||||
const cwd = "/repo";
|
|
||||||
const workspaceId = "workspace-a";
|
|
||||||
const queryKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
|
|
||||||
const observer = new QueryObserver(queryClient, {
|
|
||||||
queryKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: workspaceTerminalsPushRoute({
|
|
||||||
enabled: true,
|
|
||||||
serverId,
|
|
||||||
cwd,
|
|
||||||
workspaceId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const unsubscribeObserver = observer.subscribe(() => undefined);
|
|
||||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
|
||||||
|
|
||||||
expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
|
|
||||||
|
|
||||||
fake.emit({
|
|
||||||
type: "terminals_changed",
|
|
||||||
payload: {
|
|
||||||
cwd,
|
|
||||||
terminals: [
|
|
||||||
{ id: "terminal-a", name: "Main", workspaceId },
|
|
||||||
{ id: "terminal-b", name: "Sibling", workspaceId: "workspace-b" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(queryKey)).toEqual({
|
|
||||||
cwd,
|
|
||||||
terminals: [{ id: "terminal-a", name: "Main", workspaceId }],
|
|
||||||
requestId: expect.stringMatching(/^terminals-changed-/),
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribeObserver();
|
|
||||||
|
|
||||||
expect(fake.unsubscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-sends active push subscriptions after reconnect", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const fake = createFakeClient();
|
|
||||||
const serverId = "server-1";
|
|
||||||
const cwd = "/repo";
|
|
||||||
const workspaceId = "workspace-a";
|
|
||||||
const checkoutDiffKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
|
|
||||||
const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`;
|
|
||||||
const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
|
|
||||||
const checkoutDiffObserver = new QueryObserver(queryClient, {
|
|
||||||
queryKey: checkoutDiffKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: checkoutDiffPushRoute({
|
|
||||||
enabled: true,
|
|
||||||
serverId,
|
|
||||||
subscriptionId: checkoutDiffSubscriptionId,
|
|
||||||
cwd,
|
|
||||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const terminalObserver = new QueryObserver(queryClient, {
|
|
||||||
queryKey: terminalKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: workspaceTerminalsPushRoute({
|
|
||||||
enabled: true,
|
|
||||||
serverId,
|
|
||||||
cwd,
|
|
||||||
workspaceId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const unsubscribeCheckoutDiffObserver = checkoutDiffObserver.subscribe(() => undefined);
|
|
||||||
const unsubscribeTerminalObserver = terminalObserver.subscribe(() => undefined);
|
|
||||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
|
||||||
const plainCheckoutDiffObserver = new QueryObserver(queryClient, {
|
|
||||||
queryKey: checkoutDiffKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
const plainTerminalObserver = new QueryObserver(queryClient, {
|
|
||||||
queryKey: terminalKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
const unsubscribePlainCheckoutDiffObserver = plainCheckoutDiffObserver.subscribe(
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
const unsubscribePlainTerminalObserver = plainTerminalObserver.subscribe(() => undefined);
|
|
||||||
|
|
||||||
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
|
|
||||||
|
|
||||||
expect(fake.subscribeCheckoutDiffCalls).toEqual([
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
|
||||||
subscriptionId: checkoutDiffSubscriptionId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
|
||||||
subscriptionId: checkoutDiffSubscriptionId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(fake.subscribeTerminalCalls).toEqual([
|
|
||||||
{ cwd, workspaceId },
|
|
||||||
{ cwd, workspaceId },
|
|
||||||
]);
|
|
||||||
|
|
||||||
fake.emit({
|
|
||||||
type: "terminals_changed",
|
|
||||||
payload: {
|
|
||||||
cwd,
|
|
||||||
terminals: [
|
|
||||||
{ id: "terminal-a", name: "Main", workspaceId },
|
|
||||||
{ id: "terminal-b", name: "Sibling", workspaceId: "workspace-b" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(terminalKey)).toEqual({
|
|
||||||
cwd,
|
|
||||||
terminals: [{ id: "terminal-a", name: "Main", workspaceId }],
|
|
||||||
requestId: expect.stringMatching(/^terminals-changed-/),
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribePlainCheckoutDiffObserver();
|
|
||||||
unsubscribePlainTerminalObserver();
|
|
||||||
unsubscribeCheckoutDiffObserver();
|
|
||||||
unsubscribeTerminalObserver();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes terminal pushes after another observer attaches without push metadata", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const fake = createFakeClient();
|
|
||||||
const serverId = "server-1";
|
|
||||||
const cwd = "/repo";
|
|
||||||
const workspaceId = "workspace-a";
|
|
||||||
const queryKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
|
|
||||||
const pushObserver = new QueryObserver(queryClient, {
|
|
||||||
queryKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: workspaceTerminalsPushRoute({
|
|
||||||
enabled: true,
|
|
||||||
serverId,
|
|
||||||
cwd,
|
|
||||||
workspaceId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const unsubscribePushObserver = pushObserver.subscribe(() => undefined);
|
|
||||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
|
||||||
expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
|
|
||||||
|
|
||||||
const plainObserver = new QueryObserver(queryClient, {
|
|
||||||
queryKey,
|
|
||||||
queryFn: skipToken,
|
|
||||||
enabled: true,
|
|
||||||
gcTime: Infinity,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
const unsubscribePlainObserver = plainObserver.subscribe(() => undefined);
|
|
||||||
|
|
||||||
fake.emit({
|
|
||||||
type: "terminals_changed",
|
|
||||||
payload: {
|
|
||||||
cwd,
|
|
||||||
terminals: [
|
|
||||||
{
|
|
||||||
id: "terminal-a",
|
|
||||||
name: "Main",
|
|
||||||
workspaceId,
|
|
||||||
activity: { state: "idle", attentionReason: "needs_input", changedAt: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(queryKey)).toEqual({
|
|
||||||
cwd,
|
|
||||||
terminals: [
|
|
||||||
{
|
|
||||||
id: "terminal-a",
|
|
||||||
name: "Main",
|
|
||||||
workspaceId,
|
|
||||||
activity: { state: "idle", attentionReason: "needs_input", changedAt: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
requestId: expect.stringMatching(/^terminals-changed-/),
|
|
||||||
});
|
|
||||||
expect(fake.unsubscribeTerminalCalls).toEqual([]);
|
|
||||||
|
|
||||||
unsubscribePlainObserver();
|
|
||||||
unsubscribePushObserver();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invalidates only the reconnect-repair scopes for one server", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const serverId = "server-1";
|
|
||||||
const otherServerId = "server-2";
|
|
||||||
const providerKey = providersSnapshotQueryKey(serverId);
|
|
||||||
const daemonConfigKey = daemonConfigQueryKey(serverId);
|
|
||||||
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false);
|
|
||||||
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
|
|
||||||
const otherProviderKey = providersSnapshotQueryKey(otherServerId);
|
|
||||||
|
|
||||||
queryClient.setQueryData(providerKey, { entries: [], generatedAt: "now", requestId: "p" });
|
|
||||||
queryClient.setQueryData(daemonConfigKey, daemonConfig);
|
|
||||||
queryClient.setQueryData(diffKey, { cwd: "/repo", files: [], error: null, requestId: "d" });
|
|
||||||
queryClient.setQueryData(terminalKey, { cwd: "/repo", terminals: [], requestId: "t" });
|
|
||||||
queryClient.setQueryData(otherProviderKey, {
|
|
||||||
entries: [],
|
|
||||||
generatedAt: "now",
|
|
||||||
requestId: "other",
|
|
||||||
});
|
|
||||||
|
|
||||||
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
|
|
||||||
|
|
||||||
expect(queryClient.getQueryState(providerKey)?.isInvalidated).toBe(true);
|
|
||||||
expect(queryClient.getQueryState(daemonConfigKey)?.isInvalidated).toBe(true);
|
|
||||||
expect(queryClient.getQueryState(diffKey)?.isInvalidated).toBe(true);
|
|
||||||
expect(queryClient.getQueryState(terminalKey)?.isInvalidated).toBe(true);
|
|
||||||
expect(queryClient.getQueryState(otherProviderKey)?.isInvalidated).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,754 +0,0 @@
|
|||||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
|
||||||
import type {
|
|
||||||
ListTerminalsResponse,
|
|
||||||
MutableDaemonConfig,
|
|
||||||
SessionOutboundMessage,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
|
||||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
|
||||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
|
||||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
|
||||||
import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
|
|
||||||
|
|
||||||
type ProvidersSnapshotUpdateMessage = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "providers_snapshot_update" }
|
|
||||||
>;
|
|
||||||
type CheckoutDiffUpdateMessage = Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>;
|
|
||||||
type SubscribeCheckoutDiffResponseMessage = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "subscribe_checkout_diff_response" }
|
|
||||||
>;
|
|
||||||
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
|
||||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
|
||||||
type ServerDataEventType =
|
|
||||||
| "providers_snapshot_update"
|
|
||||||
| "checkout_diff_update"
|
|
||||||
| "subscribe_checkout_diff_response"
|
|
||||||
| "status"
|
|
||||||
| "terminals_changed";
|
|
||||||
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
|
||||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
|
||||||
|
|
||||||
interface CheckoutDiffCompare {
|
|
||||||
mode: "uncommitted" | "base";
|
|
||||||
baseRef?: string;
|
|
||||||
ignoreWhitespace?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CheckoutDiffRoute {
|
|
||||||
domain: "checkoutDiff";
|
|
||||||
enabled: boolean;
|
|
||||||
serverId: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
cwd: string;
|
|
||||||
compare: CheckoutDiffCompare;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspaceTerminalsRoute {
|
|
||||||
domain: "workspaceTerminals";
|
|
||||||
enabled: boolean;
|
|
||||||
serverId: string;
|
|
||||||
cwd: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute;
|
|
||||||
|
|
||||||
export interface ServerDataQueryMeta extends Record<string, unknown> {
|
|
||||||
serverData: ServerDataRoute;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
|
||||||
|
|
||||||
interface ServerDataPushClient {
|
|
||||||
on<TType extends ServerDataEventType>(
|
|
||||||
type: TType,
|
|
||||||
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
|
|
||||||
): () => void;
|
|
||||||
subscribeCheckoutDiff(
|
|
||||||
cwd: string,
|
|
||||||
compare: CheckoutDiffCompare,
|
|
||||||
options: { subscriptionId: string; requestId?: string },
|
|
||||||
): Promise<CheckoutDiffResponsePayload>;
|
|
||||||
unsubscribeCheckoutDiff(subscriptionId: string): void;
|
|
||||||
subscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
|
|
||||||
unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PushRouterInput {
|
|
||||||
client: ServerDataPushClient;
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveServerDataSubscriptions {
|
|
||||||
checkoutDiff: Map<string, CheckoutDiffRoute>;
|
|
||||||
workspaceTerminals: Map<string, WorkspaceTerminalsRoute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReconnectRepairPolicy {
|
|
||||||
domain: string;
|
|
||||||
invalidate(input: { queryClient: QueryClient; serverId: string }): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [
|
|
||||||
{
|
|
||||||
domain: "providersSnapshot",
|
|
||||||
invalidate: ({ queryClient, serverId }) => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
domain: "daemonConfig",
|
|
||||||
invalidate: ({ queryClient, serverId }) => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: daemonConfigQueryKey(serverId) });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
domain: "checkoutDiff",
|
|
||||||
invalidate: ({ queryClient, serverId }) => {
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => isQueryForServer(query.queryKey, "checkoutDiff", serverId),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
domain: "workspaceTerminals",
|
|
||||||
invalidate: ({ queryClient, serverId }) => {
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => isQueryForServer(query.queryKey, "terminals", serverId),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const reconnectSubscriptionRepairsByServerId = new Map<string, Set<() => void>>();
|
|
||||||
|
|
||||||
export function checkoutDiffPushRoute(input: {
|
|
||||||
enabled: boolean;
|
|
||||||
serverId: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
cwd: string;
|
|
||||||
compare: CheckoutDiffCompare;
|
|
||||||
}): ServerDataQueryMeta {
|
|
||||||
return {
|
|
||||||
serverData: {
|
|
||||||
domain: "checkoutDiff",
|
|
||||||
enabled: input.enabled,
|
|
||||||
serverId: input.serverId,
|
|
||||||
subscriptionId: input.subscriptionId,
|
|
||||||
cwd: input.cwd,
|
|
||||||
compare: input.compare,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workspaceTerminalsPushRoute(input: {
|
|
||||||
enabled: boolean;
|
|
||||||
serverId: string;
|
|
||||||
cwd: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
}): ServerDataQueryMeta {
|
|
||||||
return {
|
|
||||||
serverData: {
|
|
||||||
domain: "workspaceTerminals",
|
|
||||||
enabled: input.enabled,
|
|
||||||
serverId: input.serverId,
|
|
||||||
cwd: input.cwd,
|
|
||||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateServerDataQueriesAfterReconnect(input: {
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
}): void {
|
|
||||||
for (const policy of RECONNECT_REPAIR_POLICIES) {
|
|
||||||
policy.invalidate(input);
|
|
||||||
}
|
|
||||||
for (const repairSubscriptions of reconnectSubscriptionRepairsByServerId.get(input.serverId) ??
|
|
||||||
[]) {
|
|
||||||
repairSubscriptions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyProvidersSnapshotUpdate(input: {
|
|
||||||
serverId: string;
|
|
||||||
queryClient: QueryClient;
|
|
||||||
message: ProvidersSnapshotUpdate;
|
|
||||||
}): void {
|
|
||||||
if (input.message.type !== "providers_snapshot_update") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
|
|
||||||
input.queryClient.setQueryData(queryKey, {
|
|
||||||
entries: input.message.payload.entries,
|
|
||||||
generatedAt: input.message.payload.generatedAt,
|
|
||||||
requestId: "providers_snapshot_update",
|
|
||||||
});
|
|
||||||
void input.queryClient.invalidateQueries({
|
|
||||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
|
||||||
const activeCheckoutDiffSubscriptions = new Map<string, CheckoutDiffRoute>();
|
|
||||||
const activeTerminalSubscriptions = new Map<string, WorkspaceTerminalsRoute>();
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
function reconcileSubscriptions(
|
|
||||||
fallbackActive: ActiveServerDataSubscriptions = {
|
|
||||||
checkoutDiff: activeCheckoutDiffSubscriptions,
|
|
||||||
workspaceTerminals: activeTerminalSubscriptions,
|
|
||||||
},
|
|
||||||
): void {
|
|
||||||
if (disposed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const desiredCheckoutDiffSubscriptions = new Map<string, CheckoutDiffRoute>();
|
|
||||||
const desiredTerminalSubscriptions = new Map<string, WorkspaceTerminalsRoute>();
|
|
||||||
for (const query of input.queryClient.getQueryCache().getAll()) {
|
|
||||||
const route = getActiveServerDataRoute(query, input.serverId, {
|
|
||||||
checkoutDiff: fallbackActive.checkoutDiff,
|
|
||||||
workspaceTerminals: fallbackActive.workspaceTerminals,
|
|
||||||
});
|
|
||||||
if (!route) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (route.domain === "checkoutDiff") {
|
|
||||||
desiredCheckoutDiffSubscriptions.set(route.subscriptionId, route);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
desiredTerminalSubscriptions.set(workspaceTerminalSubscriptionKey(route), route);
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcileCheckoutDiffSubscriptions({
|
|
||||||
active: activeCheckoutDiffSubscriptions,
|
|
||||||
client: input.client,
|
|
||||||
desired: desiredCheckoutDiffSubscriptions,
|
|
||||||
serverId: input.serverId,
|
|
||||||
});
|
|
||||||
reconcileTerminalSubscriptions({
|
|
||||||
active: activeTerminalSubscriptions,
|
|
||||||
client: input.client,
|
|
||||||
desired: desiredTerminalSubscriptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSubscriptionsAfterReconnect(): void {
|
|
||||||
const fallbackActive = {
|
|
||||||
checkoutDiff: new Map(activeCheckoutDiffSubscriptions),
|
|
||||||
workspaceTerminals: new Map(activeTerminalSubscriptions),
|
|
||||||
};
|
|
||||||
activeCheckoutDiffSubscriptions.clear();
|
|
||||||
activeTerminalSubscriptions.clear();
|
|
||||||
reconcileSubscriptions(fallbackActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribeQueryCache = input.queryClient.getQueryCache().subscribe((event) => {
|
|
||||||
if (
|
|
||||||
!shouldReconcileSubscriptionsForCacheEvent(event, input.serverId, {
|
|
||||||
checkoutDiff: activeCheckoutDiffSubscriptions,
|
|
||||||
workspaceTerminals: activeTerminalSubscriptions,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reconcileSubscriptions();
|
|
||||||
});
|
|
||||||
const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => {
|
|
||||||
applyProvidersSnapshotUpdate({
|
|
||||||
queryClient: input.queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const unsubscribeDaemonConfig = input.client.on("status", (message) => {
|
|
||||||
applyDaemonConfigStatus({ queryClient: input.queryClient, serverId: input.serverId, message });
|
|
||||||
});
|
|
||||||
const unsubscribeCheckoutDiffUpdate = input.client.on("checkout_diff_update", (message) => {
|
|
||||||
applyCheckoutDiffUpdate({
|
|
||||||
activeCheckoutDiffSubscriptions,
|
|
||||||
queryClient: input.queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const unsubscribeCheckoutDiffResponse = input.client.on(
|
|
||||||
"subscribe_checkout_diff_response",
|
|
||||||
(message) => {
|
|
||||||
applyCheckoutDiffSubscribeResponse({
|
|
||||||
activeCheckoutDiffSubscriptions,
|
|
||||||
queryClient: input.queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const unsubscribeTerminalsChanged = input.client.on("terminals_changed", (message) => {
|
|
||||||
applyTerminalsChanged({
|
|
||||||
activeCheckoutDiffSubscriptions,
|
|
||||||
activeTerminalSubscriptions,
|
|
||||||
queryClient: input.queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let reconnectSubscriptionRepairs = reconnectSubscriptionRepairsByServerId.get(input.serverId);
|
|
||||||
if (!reconnectSubscriptionRepairs) {
|
|
||||||
reconnectSubscriptionRepairs = new Set();
|
|
||||||
reconnectSubscriptionRepairsByServerId.set(input.serverId, reconnectSubscriptionRepairs);
|
|
||||||
}
|
|
||||||
reconnectSubscriptionRepairs.add(resetSubscriptionsAfterReconnect);
|
|
||||||
|
|
||||||
reconcileSubscriptions();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
reconnectSubscriptionRepairs.delete(resetSubscriptionsAfterReconnect);
|
|
||||||
if (reconnectSubscriptionRepairs.size === 0) {
|
|
||||||
reconnectSubscriptionRepairsByServerId.delete(input.serverId);
|
|
||||||
}
|
|
||||||
unsubscribeQueryCache();
|
|
||||||
unsubscribeProviders();
|
|
||||||
unsubscribeDaemonConfig();
|
|
||||||
unsubscribeCheckoutDiffUpdate();
|
|
||||||
unsubscribeCheckoutDiffResponse();
|
|
||||||
unsubscribeTerminalsChanged();
|
|
||||||
for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) {
|
|
||||||
unsubscribeCheckoutDiff(input.client, subscriptionId);
|
|
||||||
}
|
|
||||||
activeCheckoutDiffSubscriptions.clear();
|
|
||||||
for (const route of activeTerminalSubscriptions.values()) {
|
|
||||||
input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(route));
|
|
||||||
}
|
|
||||||
activeTerminalSubscriptions.clear();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconcileCheckoutDiffSubscriptions(input: {
|
|
||||||
active: Map<string, CheckoutDiffRoute>;
|
|
||||||
client: ServerDataPushClient;
|
|
||||||
desired: Map<string, CheckoutDiffRoute>;
|
|
||||||
serverId: string;
|
|
||||||
}): void {
|
|
||||||
for (const [subscriptionId, current] of input.active) {
|
|
||||||
const desired = input.desired.get(subscriptionId);
|
|
||||||
if (desired && areCheckoutDiffRoutesEqual(current, desired)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
unsubscribeCheckoutDiff(input.client, subscriptionId);
|
|
||||||
input.active.delete(subscriptionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [subscriptionId, desired] of input.desired) {
|
|
||||||
if (input.active.has(subscriptionId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
input.active.set(subscriptionId, desired);
|
|
||||||
void input.client
|
|
||||||
.subscribeCheckoutDiff(desired.cwd, desired.compare, {
|
|
||||||
subscriptionId,
|
|
||||||
requestId: `push-router:${input.serverId}:${subscriptionId}`,
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (areCheckoutDiffRoutesEqual(input.active.get(subscriptionId), desired)) {
|
|
||||||
input.active.delete(subscriptionId);
|
|
||||||
}
|
|
||||||
console.error("[server-data] subscribeCheckoutDiff failed", {
|
|
||||||
serverId: input.serverId,
|
|
||||||
cwd: desired.cwd,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconcileTerminalSubscriptions(input: {
|
|
||||||
active: Map<string, WorkspaceTerminalsRoute>;
|
|
||||||
client: ServerDataPushClient;
|
|
||||||
desired: Map<string, WorkspaceTerminalsRoute>;
|
|
||||||
}): void {
|
|
||||||
for (const [key, current] of input.active) {
|
|
||||||
const desired = input.desired.get(key);
|
|
||||||
if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(current));
|
|
||||||
input.active.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, desired] of input.desired) {
|
|
||||||
if (input.active.has(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
input.active.set(key, desired);
|
|
||||||
input.client.subscribeTerminals(workspaceTerminalSubscriptionInput(desired));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDaemonConfigStatus(input: {
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
message: StatusMessage;
|
|
||||||
}): void {
|
|
||||||
const payload = input.message.payload;
|
|
||||||
if (!isDaemonConfigChangedPayload(payload)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.queryClient.setQueryData<MutableDaemonConfig>(
|
|
||||||
daemonConfigQueryKey(input.serverId),
|
|
||||||
payload.config,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCheckoutDiffUpdate(input: {
|
|
||||||
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
message: CheckoutDiffUpdateMessage;
|
|
||||||
}): void {
|
|
||||||
setCheckoutDiffPayload({
|
|
||||||
activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions,
|
|
||||||
queryClient: input.queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
subscriptionId: input.message.payload.subscriptionId,
|
|
||||||
payload: {
|
|
||||||
cwd: input.message.payload.cwd,
|
|
||||||
files: orderCheckoutDiffFiles(input.message.payload.files),
|
|
||||||
error: input.message.payload.error,
|
|
||||||
requestId: `subscription:${input.message.payload.subscriptionId}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCheckoutDiffSubscribeResponse(input: {
|
|
||||||
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
message: SubscribeCheckoutDiffResponseMessage;
|
|
||||||
}): void {
|
|
||||||
setCheckoutDiffPayload({
|
|
||||||
activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions,
|
|
||||||
queryClient: input.queryClient,
|
|
||||||
serverId: input.serverId,
|
|
||||||
subscriptionId: input.message.payload.subscriptionId,
|
|
||||||
payload: {
|
|
||||||
cwd: input.message.payload.cwd,
|
|
||||||
files: orderCheckoutDiffFiles(input.message.payload.files),
|
|
||||||
error: input.message.payload.error,
|
|
||||||
requestId: input.message.payload.requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCheckoutDiffPayload(input: {
|
|
||||||
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
payload: CheckoutDiffCachePayload;
|
|
||||||
}): void {
|
|
||||||
for (const query of input.queryClient.getQueryCache().getAll()) {
|
|
||||||
const route =
|
|
||||||
getServerDataRoute(query) ??
|
|
||||||
getActiveCheckoutDiffRouteForQueryKey({
|
|
||||||
active: input.activeCheckoutDiffSubscriptions,
|
|
||||||
queryKey: query.queryKey,
|
|
||||||
serverId: input.serverId,
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!route ||
|
|
||||||
route.domain !== "checkoutDiff" ||
|
|
||||||
route.serverId !== input.serverId ||
|
|
||||||
route.subscriptionId !== input.subscriptionId
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
input.queryClient.setQueryData<CheckoutDiffCachePayload>(query.queryKey, input.payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTerminalsChanged(input: {
|
|
||||||
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
|
|
||||||
activeTerminalSubscriptions: Map<string, WorkspaceTerminalsRoute>;
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
message: TerminalsChangedMessage;
|
|
||||||
}): void {
|
|
||||||
for (const query of input.queryClient.getQueryCache().getAll()) {
|
|
||||||
const route = getActiveServerDataRoute(query, input.serverId, {
|
|
||||||
checkoutDiff: input.activeCheckoutDiffSubscriptions,
|
|
||||||
workspaceTerminals: input.activeTerminalSubscriptions,
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!route ||
|
|
||||||
route.domain !== "workspaceTerminals" ||
|
|
||||||
route.cwd !== input.message.payload.cwd
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingTerminals = input.message.payload.terminals.filter(
|
|
||||||
(terminal) => terminal.workspaceId === route.workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
input.queryClient.setQueryData<ListTerminalsPayload>(query.queryKey, (current) => ({
|
|
||||||
cwd: input.message.payload.cwd,
|
|
||||||
terminals: matchingTerminals,
|
|
||||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveServerDataRoute(
|
|
||||||
query: Query,
|
|
||||||
serverId: string,
|
|
||||||
active: ActiveServerDataSubscriptions,
|
|
||||||
): ServerDataRoute | null {
|
|
||||||
if (query.getObserversCount() === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const route = getServerDataRoute(query);
|
|
||||||
if (route) {
|
|
||||||
return route.enabled && route.serverId === serverId ? route : null;
|
|
||||||
}
|
|
||||||
return getActiveRouteForQueryKey({
|
|
||||||
active,
|
|
||||||
queryKey: query.queryKey,
|
|
||||||
serverId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveRouteForQueryKey(input: {
|
|
||||||
active: ActiveServerDataSubscriptions;
|
|
||||||
queryKey: QueryKey;
|
|
||||||
serverId: string;
|
|
||||||
}): ServerDataRoute | null {
|
|
||||||
return (
|
|
||||||
getActiveTerminalRouteForQueryKey({
|
|
||||||
active: input.active.workspaceTerminals,
|
|
||||||
queryKey: input.queryKey,
|
|
||||||
serverId: input.serverId,
|
|
||||||
}) ??
|
|
||||||
getActiveCheckoutDiffRouteForQueryKey({
|
|
||||||
active: input.active.checkoutDiff,
|
|
||||||
queryKey: input.queryKey,
|
|
||||||
serverId: input.serverId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveTerminalRouteForQueryKey(input: {
|
|
||||||
active: Map<string, WorkspaceTerminalsRoute>;
|
|
||||||
queryKey: QueryKey;
|
|
||||||
serverId: string;
|
|
||||||
}): WorkspaceTerminalsRoute | null {
|
|
||||||
if (!isQueryForServer(input.queryKey, "terminals", input.serverId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const cwd = input.queryKey[2];
|
|
||||||
const workspaceId = input.queryKey[3];
|
|
||||||
if (
|
|
||||||
typeof cwd !== "string" ||
|
|
||||||
(workspaceId !== undefined && workspaceId !== null && typeof workspaceId !== "string")
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return input.active.get(`${cwd}\u0000${workspaceId ?? ""}`) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveCheckoutDiffRouteForQueryKey(input: {
|
|
||||||
active: Map<string, CheckoutDiffRoute>;
|
|
||||||
queryKey: QueryKey;
|
|
||||||
serverId: string;
|
|
||||||
}): CheckoutDiffRoute | null {
|
|
||||||
if (!isQueryForServer(input.queryKey, "checkoutDiff", input.serverId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (const route of input.active.values()) {
|
|
||||||
if (isCheckoutDiffQueryKeyForRoute(input.queryKey, route)) {
|
|
||||||
return route;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldReconcileSubscriptionsForCacheEvent(
|
|
||||||
event: QueryCacheNotifyEvent,
|
|
||||||
serverId: string,
|
|
||||||
active: ActiveServerDataSubscriptions,
|
|
||||||
): boolean {
|
|
||||||
if (!canEventChangeDesiredSubscriptions(event.type)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const route = getServerDataRoute(event.query);
|
|
||||||
if (route?.serverId === serverId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
getActiveRouteForQueryKey({
|
|
||||||
active,
|
|
||||||
queryKey: event.query.queryKey,
|
|
||||||
serverId,
|
|
||||||
}) !== null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"]): boolean {
|
|
||||||
return (
|
|
||||||
type === "added" ||
|
|
||||||
type === "removed" ||
|
|
||||||
type === "observerAdded" ||
|
|
||||||
type === "observerRemoved" ||
|
|
||||||
type === "observerOptionsUpdated"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerDataRoute(query: Query): ServerDataRoute | null {
|
|
||||||
const meta = query.meta;
|
|
||||||
if (!isRecord(meta) || !isRecord(meta.serverData)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return readServerDataRoute(meta.serverData);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute | null {
|
|
||||||
const domain = value.domain;
|
|
||||||
const enabled = value.enabled;
|
|
||||||
const serverId = value.serverId;
|
|
||||||
const cwd = value.cwd;
|
|
||||||
if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domain === "checkoutDiff") {
|
|
||||||
const subscriptionId = value.subscriptionId;
|
|
||||||
const compare = readCheckoutDiffCompare(value.compare);
|
|
||||||
if (typeof subscriptionId !== "string" || !compare) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
domain,
|
|
||||||
enabled,
|
|
||||||
serverId,
|
|
||||||
subscriptionId,
|
|
||||||
cwd,
|
|
||||||
compare,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domain === "workspaceTerminals") {
|
|
||||||
const workspaceId = value.workspaceId;
|
|
||||||
if (workspaceId !== undefined && typeof workspaceId !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
domain,
|
|
||||||
enabled,
|
|
||||||
serverId,
|
|
||||||
cwd,
|
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCheckoutDiffCompare(value: unknown): CheckoutDiffCompare | null {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const mode = value.mode;
|
|
||||||
const baseRef = value.baseRef;
|
|
||||||
const ignoreWhitespace = value.ignoreWhitespace;
|
|
||||||
if (mode !== "uncommitted" && mode !== "base") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (baseRef !== undefined && typeof baseRef !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (ignoreWhitespace !== undefined && typeof ignoreWhitespace !== "boolean") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mode,
|
|
||||||
...(baseRef ? { baseRef } : {}),
|
|
||||||
...(ignoreWhitespace !== undefined ? { ignoreWhitespace } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function areCheckoutDiffRoutesEqual(
|
|
||||||
left: CheckoutDiffRoute | undefined,
|
|
||||||
right: CheckoutDiffRoute,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
left?.serverId === right.serverId &&
|
|
||||||
left.subscriptionId === right.subscriptionId &&
|
|
||||||
left.cwd === right.cwd &&
|
|
||||||
left.compare.mode === right.compare.mode &&
|
|
||||||
left.compare.baseRef === right.compare.baseRef &&
|
|
||||||
left.compare.ignoreWhitespace === right.compare.ignoreWhitespace
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffRoute): boolean {
|
|
||||||
return (
|
|
||||||
queryKey[0] === "checkoutDiff" &&
|
|
||||||
queryKey[1] === route.serverId &&
|
|
||||||
queryKey[2] === route.cwd &&
|
|
||||||
queryKey[3] === route.compare.mode &&
|
|
||||||
queryKey[4] === (route.compare.baseRef ?? "") &&
|
|
||||||
queryKey[5] === (route.compare.ignoreWhitespace === true)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function areWorkspaceTerminalsRoutesEqual(
|
|
||||||
left: WorkspaceTerminalsRoute,
|
|
||||||
right: WorkspaceTerminalsRoute,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
left.serverId === right.serverId &&
|
|
||||||
left.cwd === right.cwd &&
|
|
||||||
left.workspaceId === right.workspaceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceTerminalSubscriptionKey(route: WorkspaceTerminalsRoute): string {
|
|
||||||
return `${route.cwd}\u0000${route.workspaceId ?? ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): {
|
|
||||||
cwd: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
cwd: route.cwd,
|
|
||||||
...(route.workspaceId ? { workspaceId: route.workspaceId } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void {
|
|
||||||
try {
|
|
||||||
client.unsubscribeCheckoutDiff(subscriptionId);
|
|
||||||
} catch {
|
|
||||||
// Disconnect cleanup can race with explicit subscription teardown.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean {
|
|
||||||
return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDaemonConfigChangedPayload(
|
|
||||||
payload: StatusMessage["payload"],
|
|
||||||
): payload is { status: "daemon_config_changed"; config: MutableDaemonConfig } {
|
|
||||||
return payload.status === "daemon_config_changed" && isRecord(payload.config);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
skipToken,
|
|
||||||
useQuery,
|
|
||||||
type QueryKey,
|
|
||||||
type UseQueryOptions,
|
|
||||||
type UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
|
|
||||||
type QueryFnOption<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = NonNullable<
|
|
||||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>["queryFn"]
|
|
||||||
>;
|
|
||||||
|
|
||||||
type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
|
||||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
|
||||||
| "gcTime"
|
|
||||||
| "initialData"
|
|
||||||
| "refetchOnMount"
|
|
||||||
| "refetchOnReconnect"
|
|
||||||
| "refetchOnWindowFocus"
|
|
||||||
| "staleTime"
|
|
||||||
> & {
|
|
||||||
pushEvent: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
|
||||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
|
||||||
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
|
|
||||||
> & {
|
|
||||||
dataShape: "list" | "value";
|
|
||||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
|
||||||
staleTimeMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useReplicaQuery<
|
|
||||||
TQueryFnData,
|
|
||||||
TError = Error,
|
|
||||||
TData = TQueryFnData,
|
|
||||||
TQueryKey extends QueryKey = QueryKey,
|
|
||||||
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
|
||||||
return useQuery(replicaQueryOptions(input));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFetchQuery<
|
|
||||||
TQueryFnData,
|
|
||||||
TError = Error,
|
|
||||||
TData = TQueryFnData,
|
|
||||||
TQueryKey extends QueryKey = QueryKey,
|
|
||||||
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
|
||||||
return useQuery(fetchQueryOptions(input));
|
|
||||||
}
|
|
||||||
|
|
||||||
function replicaQueryOptions<
|
|
||||||
TQueryFnData,
|
|
||||||
TError = Error,
|
|
||||||
TData = TQueryFnData,
|
|
||||||
TQueryKey extends QueryKey = QueryKey,
|
|
||||||
>(
|
|
||||||
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
|
||||||
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
|
|
||||||
const { pushEvent, meta, ...options } = input;
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
gcTime: Infinity,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
serverDataPolicy: {
|
|
||||||
class: "replica",
|
|
||||||
pushEvent,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
queryFn: options.queryFn ?? skipToken,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: Infinity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchQueryOptions<
|
|
||||||
TQueryFnData,
|
|
||||||
TError = Error,
|
|
||||||
TData = TQueryFnData,
|
|
||||||
TQueryKey extends QueryKey = QueryKey,
|
|
||||||
>(
|
|
||||||
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
|
||||||
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
|
|
||||||
if (!Number.isFinite(input.staleTimeMs)) {
|
|
||||||
throw new Error("Fetch queries must declare a finite staleTimeMs.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dataShape, meta, staleTimeMs, ...options } = input;
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}),
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
serverDataPolicy: {
|
|
||||||
class: "fetch",
|
|
||||||
dataShape,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
refetchOnMount: "always",
|
|
||||||
staleTime: staleTimeMs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -28,11 +28,6 @@ export interface DesktopDaemonLogs {
|
|||||||
contents: string;
|
contents: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopAppLogs {
|
|
||||||
logPath: string;
|
|
||||||
contents: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DesktopPairingOffer {
|
export interface DesktopPairingOffer {
|
||||||
relayEnabled: boolean;
|
relayEnabled: boolean;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
@@ -149,17 +144,6 @@ export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
|
|||||||
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
|
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
|
|
||||||
const raw = await invokeDesktopCommand("desktop_app_logs");
|
|
||||||
if (!isRecord(raw)) {
|
|
||||||
throw new Error("Unexpected desktop app logs response.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
logPath: toStringOrNull(raw.logPath) ?? "",
|
|
||||||
contents: typeof raw.contents === "string" ? raw.contents : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||||
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ interface UseBuiltInDaemonManagementInput {
|
|||||||
interface UseBuiltInDaemonManagementResult {
|
interface UseBuiltInDaemonManagementResult {
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
enable: () => Promise<DaemonManagementToggleResult | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBuiltInDaemonManagement(
|
export function useBuiltInDaemonManagement(
|
||||||
@@ -43,16 +42,12 @@ export function useBuiltInDaemonManagement(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
||||||
const reportError = useDesktopIpcErrorReporter();
|
const reportError = useDesktopIpcErrorReporter();
|
||||||
const {
|
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
|
||||||
mutate: toggleDaemonManagement,
|
DaemonManagementToggleResult,
|
||||||
mutateAsync: toggleDaemonManagementAsync,
|
Error
|
||||||
isPending: isUpdating,
|
>({
|
||||||
} = useMutation<DaemonManagementToggleResult, Error, { forceEnable: boolean }>({
|
mutationFn: async () => {
|
||||||
mutationFn: async ({ forceEnable }) => {
|
const wasManagingDaemon = settings.manageBuiltInDaemon;
|
||||||
// forceEnable takes the enable branch regardless of the persisted
|
|
||||||
// setting — the recovery affordance must never reach the stop/confirm
|
|
||||||
// path even if manageBuiltInDaemon was left true.
|
|
||||||
const wasManagingDaemon = forceEnable ? false : settings.manageBuiltInDaemon;
|
|
||||||
try {
|
try {
|
||||||
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
||||||
confirm: () =>
|
confirm: () =>
|
||||||
@@ -114,21 +109,8 @@ export function useBuiltInDaemonManagement(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleDaemonManagement({ forceEnable: false });
|
toggleDaemonManagement();
|
||||||
}, [isUpdating, toggleDaemonManagement]);
|
}, [isUpdating, toggleDaemonManagement]);
|
||||||
|
|
||||||
const enable = useCallback(async () => {
|
return { isUpdating, toggle };
|
||||||
if (isUpdating) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await toggleDaemonManagementAsync({ forceEnable: true });
|
|
||||||
} catch {
|
|
||||||
// onError has already surfaced the failure; callers only act on success.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [isUpdating, toggleDaemonManagementAsync]);
|
|
||||||
|
|
||||||
return { isUpdating, toggle, enable };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { getIsElectron } from "@/constants/platform";
|
|
||||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
|
||||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
|
||||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
|
||||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
|
||||||
|
|
||||||
export interface EnableBuiltInDaemonOption {
|
|
||||||
visible: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
|
||||||
const isElectron = getIsElectron();
|
|
||||||
const localDaemon = useLocalDaemonServerIdState();
|
|
||||||
const hosts = useHosts();
|
|
||||||
const { settings, updateSettings } = useDesktopSettings();
|
|
||||||
const { data, setStatus, refetch } = useDaemonStatus();
|
|
||||||
const { enable } = useBuiltInDaemonManagement({
|
|
||||||
daemonStatus: data?.status ?? null,
|
|
||||||
settings: settings.daemon,
|
|
||||||
updateSettings: (updates) => updateSettings({ daemon: updates }),
|
|
||||||
setStatus,
|
|
||||||
refreshStatus: refetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isLocalhostConfigured =
|
|
||||||
localDaemon.status === "resolved" &&
|
|
||||||
localDaemon.serverId !== null &&
|
|
||||||
hosts.some((host) => host.serverId === localDaemon.serverId);
|
|
||||||
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
|
||||||
|
|
||||||
const onPress = useCallback(() => {
|
|
||||||
void (async () => {
|
|
||||||
const result = await enable();
|
|
||||||
if (result?.kind === "enabled") {
|
|
||||||
router.push(buildSettingsHostSectionRoute(result.newStatus.serverId, "host"));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [enable]);
|
|
||||||
|
|
||||||
return { visible, onPress };
|
|
||||||
}
|
|
||||||
@@ -82,7 +82,6 @@ describe("app diagnostics report", () => {
|
|||||||
const host = makeHost();
|
const host = makeHost();
|
||||||
const redacted = redactAppDiagnosticReport(
|
const redacted = redactAppDiagnosticReport(
|
||||||
[
|
[
|
||||||
"Desktop app log tail",
|
|
||||||
"secret.example.test:6767",
|
"secret.example.test:6767",
|
||||||
"relay.secret.test:443",
|
"relay.secret.test:443",
|
||||||
"daemon-public-key-secret",
|
"daemon-public-key-secret",
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import {
|
|
||||||
collectDesktopDiagnosticSections,
|
|
||||||
type DesktopDiagnosticSources,
|
|
||||||
} from "./desktop-diagnostic-report";
|
|
||||||
|
|
||||||
function makeSources(): DesktopDiagnosticSources {
|
|
||||||
return {
|
|
||||||
getStatus: async () => ({
|
|
||||||
serverId: "server-1",
|
|
||||||
status: "running",
|
|
||||||
listen: "127.0.0.1:6767",
|
|
||||||
hostname: "host",
|
|
||||||
pid: 4242,
|
|
||||||
home: "/paseo/home",
|
|
||||||
version: "1.2.3",
|
|
||||||
desktopManaged: true,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
getDaemonLogs: async () => ({
|
|
||||||
logPath: "/paseo/home/daemon.log",
|
|
||||||
contents: "daemon line one\ndaemon line two",
|
|
||||||
}),
|
|
||||||
getAppLogs: async () => ({
|
|
||||||
logPath: "/logs/Paseo/main.log",
|
|
||||||
contents: "[login-shell-env] start\n[login-shell-env] failed",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("desktop diagnostic report", () => {
|
|
||||||
test("starts desktop diagnostic requests together", async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let releaseAppLogs: () => void = () => {};
|
|
||||||
const appLogGate = new Promise<void>((resolve) => {
|
|
||||||
releaseAppLogs = resolve;
|
|
||||||
});
|
|
||||||
const sources: DesktopDiagnosticSources = {
|
|
||||||
...makeSources(),
|
|
||||||
getStatus: async () => {
|
|
||||||
calls.push("status");
|
|
||||||
return makeSources().getStatus();
|
|
||||||
},
|
|
||||||
getDaemonLogs: async () => {
|
|
||||||
calls.push("daemonLogs");
|
|
||||||
return makeSources().getDaemonLogs();
|
|
||||||
},
|
|
||||||
getAppLogs: async () => {
|
|
||||||
calls.push("appLogs");
|
|
||||||
await appLogGate;
|
|
||||||
return makeSources().getAppLogs();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const resultPromise = collectDesktopDiagnosticSections(sources);
|
|
||||||
|
|
||||||
expect(calls).toEqual(["status", "daemonLogs", "appLogs"]);
|
|
||||||
releaseAppLogs();
|
|
||||||
await expect(resultPromise).resolves.toMatchObject({ status: "done" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("includes the Electron main-process log after the daemon log", async () => {
|
|
||||||
const result = await collectDesktopDiagnosticSections(makeSources());
|
|
||||||
const report = result.sections.join("\n\n");
|
|
||||||
|
|
||||||
expect(result.status).toBe("done");
|
|
||||||
expect(report).toContain(" Log path: /paseo/home/daemon.log");
|
|
||||||
expect(report).toContain(" App log path: /logs/Paseo/main.log");
|
|
||||||
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
|
|
||||||
expect(report).toContain(
|
|
||||||
"Desktop app log tail\n [login-shell-env] start\n [login-shell-env] failed",
|
|
||||||
);
|
|
||||||
expect(report.indexOf("Desktop app log tail")).toBeGreaterThan(
|
|
||||||
report.indexOf("Desktop daemon log tail"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps daemon diagnostics when the Electron app log fails", async () => {
|
|
||||||
const sources = {
|
|
||||||
...makeSources(),
|
|
||||||
getAppLogs: async () => {
|
|
||||||
throw new Error("app log unavailable");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collectDesktopDiagnosticSections(sources);
|
|
||||||
const report = result.sections.join("\n\n");
|
|
||||||
|
|
||||||
expect(result.status).toBe("failed");
|
|
||||||
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
|
|
||||||
expect(report).toContain("Desktop app log tail\n Error: app log unavailable");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import {
|
|
||||||
getDesktopAppLogs,
|
|
||||||
getDesktopDaemonLogs,
|
|
||||||
getDesktopDaemonStatus,
|
|
||||||
type DesktopAppLogs,
|
|
||||||
type DesktopDaemonLogs,
|
|
||||||
type DesktopDaemonStatus,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { formatDiagnosticSection } from "./app-diagnostic-report";
|
|
||||||
|
|
||||||
type DesktopDiagnosticStatus = "done" | "failed";
|
|
||||||
|
|
||||||
export interface DesktopDiagnosticCollectionResult {
|
|
||||||
sections: string[];
|
|
||||||
status: DesktopDiagnosticStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DesktopDiagnosticSources {
|
|
||||||
getStatus: () => Promise<DesktopDaemonStatus>;
|
|
||||||
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
|
|
||||||
getAppLogs: () => Promise<DesktopAppLogs>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = {
|
|
||||||
getStatus: getDesktopDaemonStatus,
|
|
||||||
getDaemonLogs: getDesktopDaemonLogs,
|
|
||||||
getAppLogs: getDesktopAppLogs,
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function collectDesktopDiagnosticSections(
|
|
||||||
sources: DesktopDiagnosticSources = DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES,
|
|
||||||
): Promise<DesktopDiagnosticCollectionResult> {
|
|
||||||
const sections: string[] = [];
|
|
||||||
let failed = false;
|
|
||||||
|
|
||||||
const [daemonResult, appLogsResult] = await Promise.allSettled([
|
|
||||||
Promise.all([sources.getStatus(), sources.getDaemonLogs()]),
|
|
||||||
sources.getAppLogs(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (daemonResult.status === "fulfilled") {
|
|
||||||
const [status, daemonLogs] = daemonResult.value;
|
|
||||||
const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null;
|
|
||||||
sections.unshift(...formatDesktopDaemonSections({ status, daemonLogs, appLogs }));
|
|
||||||
} else {
|
|
||||||
failed = true;
|
|
||||||
sections.unshift(
|
|
||||||
formatDiagnosticSection("Desktop", [
|
|
||||||
{ label: "Error", value: toMessage(daemonResult.reason) },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appLogsResult.status === "fulfilled") {
|
|
||||||
sections.push(formatLogTailSection("Desktop app log tail", appLogsResult.value.contents));
|
|
||||||
} else {
|
|
||||||
failed = true;
|
|
||||||
sections.push(
|
|
||||||
formatDiagnosticSection("Desktop app log tail", [
|
|
||||||
{ label: "Error", value: toMessage(appLogsResult.reason) },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: failed ? "failed" : "done",
|
|
||||||
sections,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDesktopDaemonSections(input: {
|
|
||||||
status: DesktopDaemonStatus;
|
|
||||||
daemonLogs: DesktopDaemonLogs;
|
|
||||||
appLogs: DesktopAppLogs | null;
|
|
||||||
}): string[] {
|
|
||||||
const { status, daemonLogs, appLogs } = input;
|
|
||||||
return [
|
|
||||||
formatDiagnosticSection("Desktop", [
|
|
||||||
{ label: "Daemon status", value: status.status },
|
|
||||||
{ label: "Desktop managed", value: String(status.desktopManaged) },
|
|
||||||
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
|
|
||||||
{ label: "Daemon version", value: status.version ?? "unknown" },
|
|
||||||
{ label: "Daemon home", value: status.home || "unknown" },
|
|
||||||
{ label: "Log path", value: daemonLogs.logPath || "unknown" },
|
|
||||||
{ label: "App log path", value: appLogs?.logPath || "unavailable" },
|
|
||||||
{ label: "Error", value: status.error ?? "none" },
|
|
||||||
]),
|
|
||||||
formatLogTailSection("Desktop daemon log tail", daemonLogs.contents),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLogTailSection(title: string, contents: string): string {
|
|
||||||
return [title, contents ? indentBlock(contents) : " No log lines found"].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function indentBlock(value: string): string {
|
|
||||||
return value
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => ` ${line}`)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMessage(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo } from "react";
|
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useReplicaQuery } from "@/data/query";
|
import { useEffect, useId, useMemo } from "react";
|
||||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
||||||
import type { SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
import type { SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||||
|
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||||
|
|
||||||
interface UseCheckoutDiffQueryOptions {
|
interface UseCheckoutDiffQueryOptions {
|
||||||
@@ -44,7 +44,10 @@ export function useCheckoutDiffQuery({
|
|||||||
ignoreWhitespace,
|
ignoreWhitespace,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseCheckoutDiffQueryOptions) {
|
}: UseCheckoutDiffQueryOptions) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const hookInstanceId = useId();
|
||||||
const normalizedCompare = useMemo(
|
const normalizedCompare = useMemo(
|
||||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||||
[mode, baseRef, ignoreWhitespace],
|
[mode, baseRef, ignoreWhitespace],
|
||||||
@@ -56,26 +59,112 @@ export function useCheckoutDiffQuery({
|
|||||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||||
);
|
);
|
||||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
|
||||||
const routeEnabled = Boolean(enabled && isConnected && cwd);
|
|
||||||
|
|
||||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
const query = useQuery<CheckoutDiffQueryPayload>({
|
||||||
queryKey,
|
queryKey,
|
||||||
enabled: routeEnabled,
|
queryFn: skipToken,
|
||||||
pushEvent: "checkout_diff_update",
|
staleTime: Infinity,
|
||||||
meta: checkoutDiffPushRoute({
|
|
||||||
enabled: routeEnabled,
|
|
||||||
serverId,
|
|
||||||
subscriptionId,
|
|
||||||
cwd,
|
|
||||||
compare: {
|
|
||||||
mode: compareMode,
|
|
||||||
...(compareBaseRef ? { baseRef: compareBaseRef } : {}),
|
|
||||||
ignoreWhitespace: compareIgnoreWhitespace,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !isConnected || !cwd || !enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionId = [
|
||||||
|
"checkoutDiff",
|
||||||
|
hookInstanceId,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
compareMode,
|
||||||
|
compareBaseRef ?? "",
|
||||||
|
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||||
|
].join(":");
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
||||||
|
if (message.payload.subscriptionId !== subscriptionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||||
|
cwd: message.payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(message.payload.files),
|
||||||
|
error: message.payload.error,
|
||||||
|
requestId: `subscription:${subscriptionId}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const unsubscribeSubscribeResponse = client.on(
|
||||||
|
"subscribe_checkout_diff_response",
|
||||||
|
(message) => {
|
||||||
|
if (message.payload.subscriptionId !== subscriptionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||||
|
cwd: message.payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(message.payload.files),
|
||||||
|
error: message.payload.error,
|
||||||
|
requestId: message.payload.requestId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
void client
|
||||||
|
.subscribeCheckoutDiff(
|
||||||
|
cwd,
|
||||||
|
{
|
||||||
|
mode: compareMode,
|
||||||
|
baseRef: compareBaseRef,
|
||||||
|
ignoreWhitespace: compareIgnoreWhitespace,
|
||||||
|
},
|
||||||
|
{ subscriptionId },
|
||||||
|
)
|
||||||
|
.then((payload) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(payload.files),
|
||||||
|
error: payload.error,
|
||||||
|
requestId: payload.requestId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[useCheckoutDiffQuery] subscribeCheckoutDiff failed", {
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribeUpdate();
|
||||||
|
unsubscribeSubscribeResponse();
|
||||||
|
try {
|
||||||
|
client.unsubscribeCheckoutDiff(subscriptionId);
|
||||||
|
} catch {
|
||||||
|
// Ignore disconnect race during effect cleanup.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
client,
|
||||||
|
isConnected,
|
||||||
|
cwd,
|
||||||
|
enabled,
|
||||||
|
hookInstanceId,
|
||||||
|
serverId,
|
||||||
|
compareMode,
|
||||||
|
compareBaseRef,
|
||||||
|
compareIgnoreWhitespace,
|
||||||
|
queryKey,
|
||||||
|
queryClient,
|
||||||
|
]);
|
||||||
|
|
||||||
const payload = query.data ?? null;
|
const payload = query.data ?? null;
|
||||||
const payloadError = payload?.error ?? null;
|
const payloadError = payload?.error ?? null;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
providersSnapshotQueryKey,
|
providersSnapshotQueryKey,
|
||||||
providersSnapshotQueryRoot,
|
providersSnapshotQueryRoot,
|
||||||
providersSnapshotRequestOptions,
|
providersSnapshotRequestOptions,
|
||||||
} from "@/data/providers-snapshot";
|
} from "./providers-snapshot-query";
|
||||||
|
|
||||||
describe("providers snapshot query scope", () => {
|
describe("providers snapshot query scope", () => {
|
||||||
it("normalizes blank cwd values to the home scope", () => {
|
it("normalizes blank cwd values to the home scope", () => {
|
||||||
@@ -27,13 +27,11 @@ import {
|
|||||||
combineInitialValues,
|
combineInitialValues,
|
||||||
buildProviderDefinitionMap,
|
buildProviderDefinitionMap,
|
||||||
buildProviderDefinitionMapForStatuses,
|
buildProviderDefinitionMapForStatuses,
|
||||||
INITIAL_AGENT_FORM_RESOLUTION,
|
|
||||||
INITIAL_USER_MODIFIED,
|
INITIAL_USER_MODIFIED,
|
||||||
RESOLVABLE_PROVIDER_STATUSES,
|
RESOLVABLE_PROVIDER_STATUSES,
|
||||||
SELECTABLE_PROVIDER_STATUSES,
|
SELECTABLE_PROVIDER_STATUSES,
|
||||||
type FormInitialValues,
|
type FormInitialValues,
|
||||||
type FormState,
|
type FormState,
|
||||||
type ProviderModelsByProvider,
|
|
||||||
} from "@/provider-selection/resolve-agent-form";
|
} from "@/provider-selection/resolve-agent-form";
|
||||||
|
|
||||||
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
||||||
@@ -87,6 +85,7 @@ function shouldAutoSelectServerId(input: {
|
|||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isCreateFlow: boolean;
|
isCreateFlow: boolean;
|
||||||
isPreferencesLoading: boolean;
|
isPreferencesLoading: boolean;
|
||||||
|
hasResolved: boolean;
|
||||||
userModifiedServerId: boolean;
|
userModifiedServerId: boolean;
|
||||||
initialServerId: string | null | undefined;
|
initialServerId: string | null | undefined;
|
||||||
currentServerId: string | null;
|
currentServerId: string | null;
|
||||||
@@ -95,47 +94,20 @@ function shouldAutoSelectServerId(input: {
|
|||||||
isVisible,
|
isVisible,
|
||||||
isCreateFlow,
|
isCreateFlow,
|
||||||
isPreferencesLoading,
|
isPreferencesLoading,
|
||||||
|
hasResolved,
|
||||||
userModifiedServerId,
|
userModifiedServerId,
|
||||||
initialServerId,
|
initialServerId,
|
||||||
currentServerId,
|
currentServerId,
|
||||||
} = input;
|
} = input;
|
||||||
if (!isVisible || !isCreateFlow) return false;
|
if (!isVisible || !isCreateFlow) return false;
|
||||||
if (isPreferencesLoading) return false;
|
if (isPreferencesLoading) return false;
|
||||||
|
if (!hasResolved) return false;
|
||||||
if (userModifiedServerId) return false;
|
if (userModifiedServerId) return false;
|
||||||
if (initialServerId !== undefined) return false;
|
if (initialServerId !== undefined) return false;
|
||||||
if (currentServerId) return false;
|
if (currentServerId) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolutionIntentKeyPart(value: string | null | undefined): string {
|
|
||||||
if (value === undefined) return "undefined";
|
|
||||||
if (value === null) return "null";
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResolutionIntentKey(initialValues: FormInitialValues | undefined): string {
|
|
||||||
if (!initialValues) return "none";
|
|
||||||
// workingDir seeds the open request, but locked cwd updates must not re-run
|
|
||||||
// provider/model/mode resolution after the form has settled.
|
|
||||||
return [
|
|
||||||
resolutionIntentKeyPart(initialValues.serverId),
|
|
||||||
resolutionIntentKeyPart(initialValues.provider),
|
|
||||||
resolutionIntentKeyPart(initialValues.modeId),
|
|
||||||
resolutionIntentKeyPart(initialValues.model),
|
|
||||||
resolutionIntentKeyPart(initialValues.thinkingOptionId),
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSnapshotDataForResolution(input: {
|
|
||||||
serverId: string | null;
|
|
||||||
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
|
||||||
}): boolean {
|
|
||||||
if (!input.serverId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.snapshotEntries !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSelectedProviderModes(input: {
|
function resolveSelectedProviderModes(input: {
|
||||||
selectedEntry: ProviderSnapshotEntry | null;
|
selectedEntry: ProviderSnapshotEntry | null;
|
||||||
provider: AgentProvider | null;
|
provider: AgentProvider | null;
|
||||||
@@ -161,16 +133,6 @@ function buildAllProviderModels(
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProviderModelsByProvider(
|
|
||||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
|
||||||
): ProviderModelsByProvider {
|
|
||||||
const map: ProviderModelsByProvider = new Map();
|
|
||||||
for (const entry of snapshotEntries ?? []) {
|
|
||||||
map.set(entry.provider, entry.models ?? null);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistProviderPreferences(input: {
|
async function persistProviderPreferences(input: {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
formState: FormState;
|
formState: FormState;
|
||||||
@@ -213,7 +175,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||||
|
|
||||||
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(
|
const [{ form: formState, userModified }, dispatch] = useReducer(
|
||||||
resolveAgentForm,
|
resolveAgentForm,
|
||||||
initialServerId,
|
initialServerId,
|
||||||
(serverId) => ({
|
(serverId) => ({
|
||||||
@@ -226,7 +188,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
userModified: INITIAL_USER_MODIFIED,
|
userModified: INITIAL_USER_MODIFIED,
|
||||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -235,9 +196,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
reducerStateRef.current = { form: formState, userModified };
|
reducerStateRef.current = { form: formState, userModified };
|
||||||
}, [formState, userModified]);
|
}, [formState, userModified]);
|
||||||
|
|
||||||
|
const hasResolvedRef = useRef(false);
|
||||||
|
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
dispatch({ type: "RESET" });
|
dispatch({ type: "RESET" });
|
||||||
|
hasResolvedRef.current = false;
|
||||||
|
hydrationPreferencesRef.current = null;
|
||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
@@ -279,10 +245,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
() => buildAllProviderModels(snapshotEntries),
|
() => buildAllProviderModels(snapshotEntries),
|
||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotProviderModelsByProvider = useMemo(
|
|
||||||
() => buildProviderModelsByProvider(snapshotEntries),
|
|
||||||
[snapshotEntries],
|
|
||||||
);
|
|
||||||
const snapshotModelSelectorProviders = useMemo(
|
const snapshotModelSelectorProviders = useMemo(
|
||||||
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
@@ -314,52 +276,37 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
() => combineInitialValues(initialValues, initialServerId),
|
() => combineInitialValues(initialValues, initialServerId),
|
||||||
[initialValues, initialServerId],
|
[initialValues, initialServerId],
|
||||||
);
|
);
|
||||||
const resolutionIntentKey = useMemo(
|
|
||||||
() => buildResolutionIntentKey(combinedInitialValues),
|
|
||||||
[combinedInitialValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || !isCreateFlow) {
|
if (!isVisible || !isCreateFlow) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({ type: "REQUEST_RESOLUTION" });
|
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||||
}, [isVisible, isCreateFlow, resolutionIntentKey]);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
if (!hasResolvedRef.current) {
|
||||||
if (!isVisible || !isCreateFlow || resolution.status !== "pending") {
|
hydrationPreferencesRef.current = preferences;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPreferencesLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!hasSnapshotDataForResolution({
|
|
||||||
serverId: formState.serverId,
|
|
||||||
snapshotEntries,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "COMPLETE_RESOLUTION",
|
type: "RESOLVE",
|
||||||
initialValues: combinedInitialValues,
|
initialValues: combinedInitialValues,
|
||||||
preferences,
|
preferences: hydrationPreferences,
|
||||||
providerModelsByProvider: snapshotProviderModelsByProvider,
|
availableModels,
|
||||||
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
|
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
hasResolvedRef.current = true;
|
||||||
}, [
|
}, [
|
||||||
combinedInitialValues,
|
isVisible,
|
||||||
formState.serverId,
|
|
||||||
isCreateFlow,
|
isCreateFlow,
|
||||||
isPreferencesLoading,
|
isPreferencesLoading,
|
||||||
isVisible,
|
combinedInitialValues,
|
||||||
preferences,
|
preferences,
|
||||||
resolution.status,
|
availableModels,
|
||||||
snapshotEntries,
|
|
||||||
snapshotProviderModelsByProvider,
|
|
||||||
snapshotResolvableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -369,9 +316,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isVisible,
|
isVisible,
|
||||||
isCreateFlow,
|
isCreateFlow,
|
||||||
isPreferencesLoading,
|
isPreferencesLoading,
|
||||||
|
hasResolved: hasResolvedRef.current,
|
||||||
userModifiedServerId: userModified.serverId,
|
userModifiedServerId: userModified.serverId,
|
||||||
initialServerId: combinedInitialValues?.serverId,
|
initialServerId: combinedInitialValues?.serverId,
|
||||||
currentServerId: formState.serverId,
|
currentServerId: reducerStateRef.current.form.serverId,
|
||||||
});
|
});
|
||||||
if (!canAutoSelectServerId) return;
|
if (!canAutoSelectServerId) return;
|
||||||
|
|
||||||
@@ -386,7 +334,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
isVisible,
|
isVisible,
|
||||||
onlineServerIds,
|
onlineServerIds,
|
||||||
onlineServerIdsKey,
|
onlineServerIdsKey,
|
||||||
formState.serverId,
|
|
||||||
userModified.serverId,
|
userModified.serverId,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
]);
|
]);
|
||||||
@@ -562,6 +509,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
? providerDefinitionMap.get(formState.provider)
|
? providerDefinitionMap.get(formState.provider)
|
||||||
: undefined;
|
: undefined;
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptionsRaw = effectiveModel?.thinkingOptions;
|
const availableThinkingOptionsRaw = effectiveModel?.thinkingOptions;
|
||||||
const availableThinkingOptions = useMemo(
|
const availableThinkingOptions = useMemo(
|
||||||
() => availableThinkingOptionsRaw ?? [],
|
() => availableThinkingOptionsRaw ?? [],
|
||||||
@@ -581,7 +529,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
setProviderFromUser,
|
setProviderFromUser,
|
||||||
selectedMode: formState.modeId,
|
selectedMode: formState.modeId,
|
||||||
setModeFromUser,
|
setModeFromUser,
|
||||||
selectedModel: formState.model,
|
selectedModel: resolvedModelId,
|
||||||
setModelFromUser,
|
setModelFromUser,
|
||||||
selectedThinkingOptionId: formState.thinkingOptionId,
|
selectedThinkingOptionId: formState.thinkingOptionId,
|
||||||
setThinkingOptionFromUser,
|
setThinkingOptionFromUser,
|
||||||
@@ -612,7 +560,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
formState.serverId,
|
formState.serverId,
|
||||||
formState.provider,
|
formState.provider,
|
||||||
formState.modeId,
|
formState.modeId,
|
||||||
formState.model,
|
resolvedModelId,
|
||||||
formState.thinkingOptionId,
|
formState.thinkingOptionId,
|
||||||
formState.workingDir,
|
formState.workingDir,
|
||||||
setSelectedServerId,
|
setSelectedServerId,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||||
import { useReplicaQuery } from "@/data/query";
|
|
||||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
|
export function daemonConfigQueryKey(serverId: string | null) {
|
||||||
|
return ["daemon-config", serverId] as const;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseDaemonConfigResult {
|
interface UseDaemonConfigResult {
|
||||||
config: MutableDaemonConfig | null;
|
config: MutableDaemonConfig | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -19,10 +21,10 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const queryKey = useMemo(() => daemonConfigQueryKey(serverId), [serverId]);
|
const queryKey = useMemo(() => daemonConfigQueryKey(serverId), [serverId]);
|
||||||
|
|
||||||
const configQuery = useReplicaQuery({
|
const configQuery = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
enabled: Boolean(serverId && client && isConnected),
|
enabled: Boolean(serverId && client && isConnected),
|
||||||
pushEvent: "status:daemon_config_changed",
|
staleTime: Infinity,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
@@ -32,6 +34,22 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !isConnected || !serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.on("status", (message) => {
|
||||||
|
if (message.type !== "status") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.payload.status !== "daemon_config_changed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(queryKey, message.payload.config as MutableDaemonConfig);
|
||||||
|
});
|
||||||
|
}, [client, isConnected, queryClient, queryKey, serverId]);
|
||||||
|
|
||||||
const patchConfig = useCallback(
|
const patchConfig = useCallback(
|
||||||
async (patch: MutableDaemonConfigPatch) => {
|
async (patch: MutableDaemonConfigPatch) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useLocalDaemonServerIdQuery() {
|
export function useLocalDaemonServerId(): string | null {
|
||||||
const isDesktopApp = shouldUseDesktopDaemon();
|
const isDesktopApp = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
|
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
|
||||||
queryFn: loadDesktopDaemonServerId,
|
queryFn: loadDesktopDaemonServerId,
|
||||||
enabled: isDesktopApp,
|
enabled: isDesktopApp,
|
||||||
@@ -30,11 +30,6 @@ function useLocalDaemonServerIdQuery() {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export function useLocalDaemonServerId(): string | null {
|
|
||||||
const isDesktopApp = shouldUseDesktopDaemon();
|
|
||||||
const query = useLocalDaemonServerIdQuery();
|
|
||||||
|
|
||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return null;
|
return null;
|
||||||
@@ -43,27 +38,6 @@ export function useLocalDaemonServerId(): string | null {
|
|||||||
return query.data?.serverId ?? null;
|
return query.data?.serverId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalDaemonServerIdState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "error" }
|
|
||||||
| { status: "resolved"; serverId: string | null };
|
|
||||||
|
|
||||||
export function useLocalDaemonServerIdState(): LocalDaemonServerIdState {
|
|
||||||
const isDesktopApp = shouldUseDesktopDaemon();
|
|
||||||
const query = useLocalDaemonServerIdQuery();
|
|
||||||
|
|
||||||
if (!isDesktopApp) {
|
|
||||||
return { status: "resolved", serverId: null };
|
|
||||||
}
|
|
||||||
if (query.isError) {
|
|
||||||
return { status: "error" };
|
|
||||||
}
|
|
||||||
if (query.isSuccess) {
|
|
||||||
return { status: "resolved", serverId: query.data.serverId };
|
|
||||||
}
|
|
||||||
return { status: "loading" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIsLocalDaemon(serverId: string): boolean {
|
export function useIsLocalDaemon(serverId: string): boolean {
|
||||||
const normalizedServerId = serverId.trim();
|
const normalizedServerId = serverId.trim();
|
||||||
const localServerId = useLocalDaemonServerId();
|
const localServerId = useLocalDaemonServerId();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { projectsQueryKey } from "@/hooks/use-projects";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
|
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ export function useOpenProject(
|
|||||||
const normalizedServerId = serverId?.trim() ?? "";
|
const normalizedServerId = serverId?.trim() ?? "";
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const canAddProject = useSessionStore((state) =>
|
const canAddProject = useSessionStore((state) =>
|
||||||
normalizedServerId
|
normalizedServerId
|
||||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
||||||
@@ -28,6 +31,13 @@ export function useOpenProject(
|
|||||||
addEmptyProject,
|
addEmptyProject,
|
||||||
setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
|
// The aggregated projects query derives the project list from a fetch
|
||||||
|
// that now includes empty projects; refetch so a freshly-added project
|
||||||
|
// (no workspace yet) is immediately editable instead of only after a
|
||||||
|
// restart.
|
||||||
|
if (result.ok) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -36,6 +46,7 @@ export function useOpenProject(
|
|||||||
client,
|
client,
|
||||||
isConnected,
|
isConnected,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
|
queryClient,
|
||||||
setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +1,92 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
deriveProjectsFromReplica,
|
fetchAggregatedProjects,
|
||||||
type ProjectHostReplica,
|
type ProjectsHostInput,
|
||||||
type ProjectHostRuntimeState,
|
type ProjectsRuntime,
|
||||||
} from "@/hooks/use-projects";
|
type ProjectsRuntimeSnapshot,
|
||||||
|
} from "@/projects/aggregated-projects";
|
||||||
|
|
||||||
function runtimeState(input: {
|
type FetchWorkspaces = DaemonClient["fetchWorkspaces"];
|
||||||
|
type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>;
|
||||||
|
|
||||||
|
interface HostFixture {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
isOnline: boolean;
|
serverName: string;
|
||||||
isLoading?: boolean;
|
status: "online" | "offline" | "missing-snapshot";
|
||||||
isFetching?: boolean;
|
workspaces?: WorkspaceDescriptorPayload[] | Error;
|
||||||
error?: string | null;
|
}
|
||||||
}): ProjectHostRuntimeState {
|
|
||||||
|
interface RuntimeAdapter extends ProjectsRuntime {
|
||||||
|
fetchCalls: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntime(hosts: HostFixture[]): RuntimeAdapter {
|
||||||
|
const snapshots = new Map<string, ProjectsRuntimeSnapshot | null>();
|
||||||
|
const clients = new Map<string, Pick<DaemonClient, "fetchWorkspaces"> | null>();
|
||||||
|
const fetchCalls = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (host.status === "missing-snapshot") {
|
||||||
|
snapshots.set(host.serverId, null);
|
||||||
|
} else {
|
||||||
|
snapshots.set(host.serverId, { connectionStatus: host.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.workspaces === undefined) {
|
||||||
|
clients.set(host.serverId, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = host.workspaces;
|
||||||
|
const fetchWorkspaces: FetchWorkspaces = async () => {
|
||||||
|
fetchCalls.set(host.serverId, (fetchCalls.get(host.serverId) ?? 0) + 1);
|
||||||
|
if (workspaces instanceof Error) {
|
||||||
|
throw workspaces;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestId: `req-${host.serverId}`,
|
||||||
|
entries: workspaces,
|
||||||
|
emptyProjects: [],
|
||||||
|
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||||
|
} satisfies FetchWorkspacesResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
clients.set(host.serverId, { fetchWorkspaces });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverId: input.serverId,
|
getClient: (serverId) => clients.get(serverId) ?? null,
|
||||||
isOnline: input.isOnline,
|
getSnapshot: (serverId) => snapshots.get(serverId),
|
||||||
isLoading: input.isLoading ?? false,
|
fetchCalls,
|
||||||
isFetching: input.isFetching ?? false,
|
|
||||||
error: input.error ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hostInputs(hosts: HostFixture[]): ProjectsHostInput[] {
|
||||||
|
return hosts.map((host) => ({ serverId: host.serverId, serverName: host.serverName }));
|
||||||
|
}
|
||||||
|
|
||||||
function workspace(input: {
|
function workspace(input: {
|
||||||
id: string;
|
id: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
}): WorkspaceDescriptor {
|
}): WorkspaceDescriptorPayload {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
projectId: input.projectKey,
|
projectId: input.projectKey,
|
||||||
projectDisplayName: input.projectName,
|
projectDisplayName: input.projectName,
|
||||||
projectCustomName: null,
|
|
||||||
projectRootPath: input.cwd,
|
projectRootPath: input.cwd,
|
||||||
workspaceDirectory: input.cwd,
|
workspaceDirectory: input.cwd,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "local_checkout",
|
workspaceKind: "local_checkout",
|
||||||
name: input.id,
|
name: input.id,
|
||||||
title: null,
|
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
status: "done",
|
status: "done",
|
||||||
statusEnteredAt: null,
|
statusEnteredAt: null,
|
||||||
|
activityAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
gitRuntime: {
|
gitRuntime: {
|
||||||
@@ -71,26 +115,13 @@ function workspace(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyProject(input: {
|
describe("fetchAggregatedProjects", () => {
|
||||||
projectKey: string;
|
it("calls every online host's client and aggregates projects sorted by display name", async () => {
|
||||||
projectName: string;
|
const hosts: HostFixture[] = [
|
||||||
cwd: string;
|
|
||||||
}): EmptyProjectDescriptor {
|
|
||||||
return {
|
|
||||||
projectId: input.projectKey,
|
|
||||||
projectDisplayName: input.projectName,
|
|
||||||
projectCustomName: null,
|
|
||||||
projectRootPath: input.cwd,
|
|
||||||
projectKind: "git",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("deriveProjectsFromReplica", () => {
|
|
||||||
it("aggregates store workspaces and empty projects sorted by display name", () => {
|
|
||||||
const replicas: ProjectHostReplica[] = [
|
|
||||||
{
|
{
|
||||||
serverId: "local",
|
serverId: "local",
|
||||||
serverName: "Local",
|
serverName: "Local",
|
||||||
|
status: "online",
|
||||||
workspaces: [
|
workspaces: [
|
||||||
workspace({
|
workspace({
|
||||||
id: "z-main",
|
id: "z-main",
|
||||||
@@ -100,50 +131,41 @@ describe("deriveProjectsFromReplica", () => {
|
|||||||
remoteUrl: "https://github.com/acme/zeta.git",
|
remoteUrl: "https://github.com/acme/zeta.git",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
emptyProjects: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: "laptop",
|
serverId: "laptop",
|
||||||
serverName: "Laptop",
|
serverName: "Laptop",
|
||||||
workspaces: [],
|
status: "online",
|
||||||
emptyProjects: [
|
workspaces: [
|
||||||
emptyProject({
|
workspace({
|
||||||
|
id: "a-main",
|
||||||
projectKey: "remote:github.com/acme/alpha",
|
projectKey: "remote:github.com/acme/alpha",
|
||||||
projectName: "acme/alpha",
|
projectName: "acme/alpha",
|
||||||
cwd: "/repo/alpha",
|
cwd: "/repo/alpha",
|
||||||
|
remoteUrl: "https://github.com/acme/alpha.git",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const runtime = createRuntime(hosts);
|
||||||
|
|
||||||
const result = deriveProjectsFromReplica({
|
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||||
replicas,
|
|
||||||
runtimeStates: [
|
|
||||||
runtimeState({ serverId: "local", isOnline: true }),
|
|
||||||
runtimeState({ serverId: "laptop", isOnline: false }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
|
expect(runtime.fetchCalls.get("local")).toBe(1);
|
||||||
|
expect(runtime.fetchCalls.get("laptop")).toBe(1);
|
||||||
expect(result.projects.map((project) => project.projectName)).toEqual([
|
expect(result.projects.map((project) => project.projectName)).toEqual([
|
||||||
"acme/alpha",
|
"acme/alpha",
|
||||||
"acme/zeta",
|
"acme/zeta",
|
||||||
]);
|
]);
|
||||||
expect(result.projects[0]?.hosts).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
serverId: "laptop",
|
|
||||||
isOnline: false,
|
|
||||||
repoRoot: "/repo/alpha",
|
|
||||||
workspaceCount: 0,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(result.hostErrors).toEqual([]);
|
expect(result.hostErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps runtime directory status into loading and host errors", () => {
|
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
|
||||||
const replicas: ProjectHostReplica[] = [
|
const hosts: HostFixture[] = [
|
||||||
{
|
{
|
||||||
serverId: "local",
|
serverId: "local",
|
||||||
serverName: "Local",
|
serverName: "Local",
|
||||||
|
status: "online",
|
||||||
workspaces: [
|
workspaces: [
|
||||||
workspace({
|
workspace({
|
||||||
id: "main",
|
id: "main",
|
||||||
@@ -153,28 +175,17 @@ describe("deriveProjectsFromReplica", () => {
|
|||||||
remoteUrl: "https://github.com/acme/app.git",
|
remoteUrl: "https://github.com/acme/app.git",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
emptyProjects: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: "laptop",
|
serverId: "laptop",
|
||||||
serverName: "Laptop",
|
serverName: "Laptop",
|
||||||
workspaces: [],
|
status: "online",
|
||||||
emptyProjects: [],
|
workspaces: new Error("laptop unavailable"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const runtime = createRuntime(hosts);
|
||||||
|
|
||||||
const result = deriveProjectsFromReplica({
|
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||||
replicas,
|
|
||||||
runtimeStates: [
|
|
||||||
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
|
|
||||||
runtimeState({
|
|
||||||
serverId: "laptop",
|
|
||||||
isOnline: true,
|
|
||||||
isLoading: true,
|
|
||||||
error: "laptop unavailable",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.projects).toEqual([
|
expect(result.projects).toEqual([
|
||||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||||
@@ -186,30 +197,61 @@ describe("deriveProjectsFromReplica", () => {
|
|||||||
message: "laptop unavailable",
|
message: "laptop unavailable",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.isLoading).toBe(true);
|
|
||||||
expect(result.isFetching).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns only the stable public project and host entry shapes", () => {
|
it("skips disconnected hosts silently without surfacing them as failures", async () => {
|
||||||
const result = deriveProjectsFromReplica({
|
const hosts: HostFixture[] = [
|
||||||
replicas: [
|
{
|
||||||
{
|
serverId: "local",
|
||||||
serverId: "local",
|
serverName: "Local",
|
||||||
serverName: "Local",
|
status: "online",
|
||||||
workspaces: [
|
workspaces: [
|
||||||
workspace({
|
workspace({
|
||||||
id: "main",
|
id: "main",
|
||||||
projectKey: "remote:github.com/acme/app",
|
projectKey: "remote:github.com/acme/app",
|
||||||
projectName: "acme/app",
|
projectName: "acme/app",
|
||||||
cwd: "/repo/app",
|
cwd: "/repo/app",
|
||||||
remoteUrl: "https://github.com/acme/app.git",
|
remoteUrl: "https://github.com/acme/app.git",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
emptyProjects: [],
|
},
|
||||||
},
|
{
|
||||||
],
|
serverId: "laptop",
|
||||||
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
|
serverName: "Laptop",
|
||||||
});
|
status: "offline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const runtime = createRuntime(hosts);
|
||||||
|
|
||||||
|
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||||
|
|
||||||
|
expect(result.hostErrors).toEqual([]);
|
||||||
|
expect(result.projects).toEqual([
|
||||||
|
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||||
|
]);
|
||||||
|
expect(runtime.fetchCalls.get("laptop")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only the stable public project and host entry shapes", async () => {
|
||||||
|
const hosts: HostFixture[] = [
|
||||||
|
{
|
||||||
|
serverId: "local",
|
||||||
|
serverName: "Local",
|
||||||
|
status: "online",
|
||||||
|
workspaces: [
|
||||||
|
workspace({
|
||||||
|
id: "main",
|
||||||
|
projectKey: "remote:github.com/acme/app",
|
||||||
|
projectName: "acme/app",
|
||||||
|
cwd: "/repo/app",
|
||||||
|
remoteUrl: "https://github.com/acme/app.git",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const runtime = createRuntime(hosts);
|
||||||
|
|
||||||
|
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||||
|
|
||||||
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
|
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
|
||||||
"githubUrl",
|
"githubUrl",
|
||||||
|
|||||||
@@ -1,45 +1,23 @@
|
|||||||
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import equal from "fast-deep-equal";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
|
import type { ProjectSummary } from "@/utils/projects";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
fetchAggregatedProjects,
|
||||||
isHostRuntimeDirectoryLoading,
|
type ProjectHostError,
|
||||||
useHosts,
|
type ProjectsHostInput,
|
||||||
type HostRuntimeSnapshot,
|
} from "@/projects/aggregated-projects";
|
||||||
} from "@/runtime/host-runtime";
|
|
||||||
import {
|
|
||||||
useSessionStore,
|
|
||||||
type EmptyProjectDescriptor,
|
|
||||||
type WorkspaceDescriptor,
|
|
||||||
} from "@/stores/session-store";
|
|
||||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
|
||||||
|
|
||||||
export interface ProjectHostError {
|
export type {
|
||||||
serverId: string;
|
ProjectHostError,
|
||||||
serverName: string;
|
ProjectsHostInput,
|
||||||
message: string;
|
ProjectsRuntime,
|
||||||
}
|
} from "@/projects/aggregated-projects";
|
||||||
|
|
||||||
export interface ProjectHostReplica {
|
export const projectsQueryKey = ["projects"] as const;
|
||||||
serverId: string;
|
|
||||||
serverName: string;
|
|
||||||
workspaces: WorkspaceDescriptor[];
|
|
||||||
emptyProjects: EmptyProjectDescriptor[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectHostRuntimeState {
|
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
|
||||||
serverId: string;
|
return hosts.map((host) => host.serverId).join("|");
|
||||||
isOnline: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
isFetching: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DerivedProjectsResult {
|
|
||||||
projects: ProjectSummary[];
|
|
||||||
hostErrors: ProjectHostError[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isFetching: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseProjectsResult {
|
export interface UseProjectsResult {
|
||||||
@@ -50,112 +28,36 @@ export interface UseProjectsResult {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProjectHostRuntimeState(
|
export function useProjects(): UseProjectsResult {
|
||||||
serverId: string,
|
const hosts = useHosts();
|
||||||
snapshot: HostRuntimeSnapshot | null,
|
|
||||||
): ProjectHostRuntimeState {
|
|
||||||
const isFetching =
|
|
||||||
snapshot?.agentDirectoryStatus === "initial_loading" ||
|
|
||||||
snapshot?.agentDirectoryStatus === "revalidating";
|
|
||||||
return {
|
|
||||||
serverId,
|
|
||||||
isOnline: snapshot?.connectionStatus === "online",
|
|
||||||
isLoading: isHostRuntimeDirectoryLoading(snapshot),
|
|
||||||
isFetching,
|
|
||||||
error: snapshot?.agentDirectoryError ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectProjectHostReplicas(
|
|
||||||
hosts: readonly { serverId: string; label: string }[],
|
|
||||||
): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] {
|
|
||||||
return (state) =>
|
|
||||||
hosts.map((host) => {
|
|
||||||
const session = state.sessions[host.serverId];
|
|
||||||
return {
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.label,
|
|
||||||
workspaces: Array.from(session?.workspaces.values() ?? []),
|
|
||||||
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deriveProjectsFromReplica(input: {
|
|
||||||
replicas: readonly ProjectHostReplica[];
|
|
||||||
runtimeStates: readonly ProjectHostRuntimeState[];
|
|
||||||
}): DerivedProjectsResult {
|
|
||||||
const runtimeByServerId = new Map(
|
|
||||||
input.runtimeStates.map((state) => [state.serverId, state] as const),
|
|
||||||
);
|
|
||||||
const hosts: ProjectHost[] = input.replicas.map((replica) => {
|
|
||||||
const runtimeState = runtimeByServerId.get(replica.serverId);
|
|
||||||
return {
|
|
||||||
serverId: replica.serverId,
|
|
||||||
serverName: replica.serverName,
|
|
||||||
isOnline: runtimeState?.isOnline ?? false,
|
|
||||||
workspaces: replica.workspaces,
|
|
||||||
emptyProjects: replica.emptyProjects,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const hostErrors = input.replicas.flatMap((replica) => {
|
|
||||||
const message = runtimeByServerId.get(replica.serverId)?.error;
|
|
||||||
return message
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
serverId: replica.serverId,
|
|
||||||
serverName: replica.serverName,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...buildProjects({ hosts }),
|
|
||||||
hostErrors,
|
|
||||||
isLoading: input.runtimeStates.some((state) => state.isLoading),
|
|
||||||
isFetching: input.runtimeStates.some((state) => state.isFetching),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostRuntimeState[] {
|
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
|
const runtimeVersion = useSyncExternalStore(
|
||||||
const runtimeSnapshotTick = useSyncExternalStore(
|
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => runtime.getVersion(),
|
() => runtime.getVersion(),
|
||||||
() => runtime.getVersion(),
|
() => runtime.getVersion(),
|
||||||
);
|
);
|
||||||
return useMemo(() => {
|
const hostInputs = useMemo<ProjectsHostInput[]>(
|
||||||
void runtimeSnapshotTick;
|
() =>
|
||||||
const nextStates = serverIds.map((serverId) =>
|
hosts.map((host) => ({
|
||||||
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
|
serverId: host.serverId,
|
||||||
);
|
serverName: host.label,
|
||||||
if (equal(previousStatesRef.current, nextStates)) {
|
})),
|
||||||
return previousStatesRef.current;
|
[hosts],
|
||||||
}
|
|
||||||
previousStatesRef.current = nextStates;
|
|
||||||
return nextStates;
|
|
||||||
}, [runtime, runtimeSnapshotTick, serverIds]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProjects(): UseProjectsResult {
|
|
||||||
const hosts = useHosts();
|
|
||||||
const runtime = getHostRuntimeStore();
|
|
||||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
|
||||||
const replicas = useStoreWithEqualityFn(useSessionStore, selectProjectHostReplicas(hosts), equal);
|
|
||||||
const runtimeStates = useProjectHostRuntimeStates(serverIds);
|
|
||||||
const derived = useMemo(
|
|
||||||
() => deriveProjectsFromReplica({ replicas, runtimeStates }),
|
|
||||||
[replicas, runtimeStates],
|
|
||||||
);
|
);
|
||||||
const refetch = useCallback(() => {
|
|
||||||
runtime.refreshAllAgentDirectories({ serverIds });
|
const projectsQuery = useQuery({
|
||||||
}, [runtime, serverIds]);
|
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
|
||||||
|
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
||||||
|
staleTime: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...derived,
|
projects: projectsQuery.data?.projects ?? [],
|
||||||
refetch,
|
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||||
|
isLoading: projectsQuery.isLoading,
|
||||||
|
isFetching: projectsQuery.isFetching,
|
||||||
|
refetch: () => {
|
||||||
|
void projectsQuery.refetch();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||||
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
|
|
||||||
import {
|
import {
|
||||||
|
applyProvidersSnapshotUpdate,
|
||||||
fetchProvidersSnapshot,
|
fetchProvidersSnapshot,
|
||||||
providersSnapshotQueryKey,
|
providersSnapshotQueryKey,
|
||||||
refreshAndApplyProvidersSnapshot,
|
refreshAndApplyProvidersSnapshot,
|
||||||
selectorOpenRefetchDecision,
|
selectorOpenRefetchDecision,
|
||||||
type ProvidersSnapshotClient,
|
type ProvidersSnapshotClient,
|
||||||
|
type ProvidersSnapshotUpdateMessage,
|
||||||
} from "./use-providers-snapshot";
|
} from "./use-providers-snapshot";
|
||||||
|
|
||||||
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||||
@@ -222,7 +223,10 @@ describe("applyProvidersSnapshotUpdate", () => {
|
|||||||
queryClient = new QueryClient();
|
queryClient = new QueryClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateMessage(entries: ProviderSnapshotEntry[], cwd?: string): ProvidersSnapshotUpdate {
|
function updateMessage(
|
||||||
|
entries: ProviderSnapshotEntry[],
|
||||||
|
cwd?: string,
|
||||||
|
): ProvidersSnapshotUpdateMessage {
|
||||||
return {
|
return {
|
||||||
type: "providers_snapshot_update",
|
type: "providers_snapshot_update",
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useReplicaQuery } from "@/data/query";
|
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||||
import { queryClient as singletonQueryClient } from "@/data/query-client";
|
|
||||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||||
import {
|
import {
|
||||||
isProvidersSnapshotHomeScope,
|
isProvidersSnapshotHomeScope,
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
providersSnapshotQueryKey,
|
providersSnapshotQueryKey,
|
||||||
providersSnapshotQueryRoot,
|
providersSnapshotQueryRoot,
|
||||||
providersSnapshotRequestOptions,
|
providersSnapshotRequestOptions,
|
||||||
} from "@/data/providers-snapshot";
|
} from "@/hooks/providers-snapshot-query";
|
||||||
|
|
||||||
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||||
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
|
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
|
||||||
@@ -26,6 +25,15 @@ export type ProvidersSnapshotClient = Pick<
|
|||||||
"getProvidersSnapshot" | "refreshProvidersSnapshot"
|
"getProvidersSnapshot" | "refreshProvidersSnapshot"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export interface ProvidersSnapshotUpdateMessage {
|
||||||
|
type: "providers_snapshot_update";
|
||||||
|
payload: {
|
||||||
|
cwd?: string;
|
||||||
|
entries: ProviderSnapshotEntry[];
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProvidersSnapshot(input: {
|
export async function fetchProvidersSnapshot(input: {
|
||||||
client: ProvidersSnapshotClient;
|
client: ProvidersSnapshotClient;
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
@@ -58,6 +66,26 @@ export async function refreshAndApplyProvidersSnapshot(input: {
|
|||||||
return refreshResult;
|
return refreshResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyProvidersSnapshotUpdate(input: {
|
||||||
|
serverId: string;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
message: ProvidersSnapshotUpdateMessage;
|
||||||
|
}): void {
|
||||||
|
if (input.message.type !== "providers_snapshot_update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
|
||||||
|
input.queryClient.setQueryData(queryKey, {
|
||||||
|
entries: input.message.payload.entries,
|
||||||
|
generatedAt: input.message.payload.generatedAt,
|
||||||
|
requestId: "providers_snapshot_update",
|
||||||
|
});
|
||||||
|
void input.queryClient.invalidateQueries({
|
||||||
|
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always";
|
export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always";
|
||||||
|
|
||||||
export function selectorOpenRefetchDecision(input: {
|
export function selectorOpenRefetchDecision(input: {
|
||||||
@@ -106,10 +134,10 @@ export function useProvidersSnapshot(
|
|||||||
|
|
||||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
|
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
|
||||||
|
|
||||||
const snapshotQuery = useReplicaQuery({
|
const snapshotQuery = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||||
pushEvent: "providers_snapshot_update",
|
staleTime: 60_000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
@@ -134,6 +162,19 @@ export function useProvidersSnapshot(
|
|||||||
});
|
});
|
||||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !supportsSnapshot || !client || !isConnected || !serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.on("providers_snapshot_update", (message) => {
|
||||||
|
if (message.type !== "providers_snapshot_update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyProvidersSnapshotUpdate({ serverId, queryClient, message });
|
||||||
|
});
|
||||||
|
}, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]);
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (providers?: AgentProvider[]) => {
|
async (providers?: AgentProvider[]) => {
|
||||||
await refreshSnapshot(providers);
|
await refreshSnapshot(providers);
|
||||||
@@ -177,7 +218,7 @@ export function prefetchProvidersSnapshot(
|
|||||||
const queryKey = providersSnapshotQueryKey(serverId, cwd);
|
const queryKey = providersSnapshotQueryKey(serverId, cwd);
|
||||||
void singletonQueryClient.prefetchQuery({
|
void singletonQueryClient.prefetchQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
staleTime: Infinity,
|
staleTime: 60_000,
|
||||||
queryFn: () => fetchProvidersSnapshot({ client, cwd }),
|
queryFn: () => fetchProvidersSnapshot({ client, cwd }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type {
|
|
||||||
AggregatedSchedule,
|
|
||||||
FetchAggregatedSchedulesResult,
|
|
||||||
FetchAggregatedSchedulesState,
|
|
||||||
} from "@/schedules/aggregated-schedules";
|
|
||||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
|
||||||
import { updateAggregatedSchedulesData } from "./use-schedule-mutations";
|
|
||||||
|
|
||||||
function schedule(overrides: Partial<AggregatedSchedule> = {}): AggregatedSchedule {
|
|
||||||
const base: ScheduleSummary = {
|
|
||||||
id: "schedule-1",
|
|
||||||
name: "Nightly",
|
|
||||||
prompt: "Run the task",
|
|
||||||
cadence: { type: "every", everyMs: 60_000 },
|
|
||||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
|
||||||
status: "active",
|
|
||||||
createdAt: "2026-07-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
|
||||||
nextRunAt: "2026-07-02T01:00:00.000Z",
|
|
||||||
lastRunAt: null,
|
|
||||||
pausedAt: null,
|
|
||||||
expiresAt: null,
|
|
||||||
maxRuns: null,
|
|
||||||
};
|
|
||||||
return { ...base, serverId: "host-a", serverName: "Host A", ...overrides };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseSchedules(schedules: AggregatedSchedule[]): AggregatedSchedule[] {
|
|
||||||
return schedules.map((entry) => ({ ...entry, status: "paused" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseAggregatedSchedules(
|
|
||||||
current: FetchAggregatedSchedulesState | undefined,
|
|
||||||
): FetchAggregatedSchedulesState | undefined {
|
|
||||||
return updateAggregatedSchedulesData(current, pauseSchedules);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("schedule mutation cache updates", () => {
|
|
||||||
it("updates the canonical loaded data field", () => {
|
|
||||||
const current: FetchAggregatedSchedulesResult = {
|
|
||||||
status: "loaded",
|
|
||||||
data: [schedule()],
|
|
||||||
hostErrors: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = updateAggregatedSchedulesData(current, pauseSchedules);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
status: "loaded",
|
|
||||||
data: [schedule({ status: "paused" })],
|
|
||||||
hostErrors: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an empty cache entry empty", () => {
|
|
||||||
const result = updateAggregatedSchedulesData(undefined, () => [schedule()]);
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves connecting cache entries untouched while updating loaded entries", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const connectingKey = [...schedulesQueryBaseKey, "connecting"];
|
|
||||||
const loadedKey = [...schedulesQueryBaseKey, "loaded"];
|
|
||||||
const connecting = { status: "connecting" } as const;
|
|
||||||
const loaded: FetchAggregatedSchedulesResult = {
|
|
||||||
status: "loaded",
|
|
||||||
data: [schedule()],
|
|
||||||
hostErrors: [],
|
|
||||||
};
|
|
||||||
queryClient.setQueryData(connectingKey, connecting);
|
|
||||||
queryClient.setQueryData(loadedKey, loaded);
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
queryClient.setQueriesData<FetchAggregatedSchedulesState>(
|
|
||||||
{ queryKey: schedulesQueryBaseKey },
|
|
||||||
pauseAggregatedSchedules,
|
|
||||||
);
|
|
||||||
}).not.toThrow();
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(connectingKey)).toEqual(connecting);
|
|
||||||
expect(queryClient.getQueryData(loadedKey)).toEqual({
|
|
||||||
status: "loaded",
|
|
||||||
data: [schedule({ status: "paused" })],
|
|
||||||
hostErrors: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -12,11 +12,11 @@ import type {
|
|||||||
UpdateScheduleOptions,
|
UpdateScheduleOptions,
|
||||||
} from "@getpaseo/client/internal/daemon-client";
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
|
||||||
import type {
|
import type {
|
||||||
AggregatedSchedule,
|
AggregatedSchedule,
|
||||||
FetchAggregatedSchedulesState,
|
FetchAggregatedSchedulesResult,
|
||||||
} from "@/schedules/aggregated-schedules";
|
} from "@/schedules/aggregated-schedules";
|
||||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||||
@@ -38,17 +38,7 @@ export interface UseScheduleMutationsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleListSnapshot {
|
interface ScheduleListSnapshot {
|
||||||
previous: Array<[QueryKey, FetchAggregatedSchedulesState | undefined]>;
|
previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>;
|
||||||
}
|
|
||||||
|
|
||||||
export function updateAggregatedSchedulesData(
|
|
||||||
current: FetchAggregatedSchedulesState | undefined,
|
|
||||||
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
|
||||||
): FetchAggregatedSchedulesState | undefined {
|
|
||||||
if (!current || current.status !== "loaded") {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return { ...current, data: updateSchedules(current.data) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
|
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
|
||||||
@@ -61,7 +51,7 @@ function requireClient(serverId: string, unavailableMessage: string): DaemonClie
|
|||||||
|
|
||||||
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
|
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
|
||||||
return {
|
return {
|
||||||
previous: queryClient.getQueriesData<FetchAggregatedSchedulesState>({
|
previous: queryClient.getQueriesData<FetchAggregatedSchedulesResult>({
|
||||||
queryKey: schedulesQueryBaseKey,
|
queryKey: schedulesQueryBaseKey,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -77,9 +67,14 @@ function updateSchedulesData(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
||||||
): void {
|
): void {
|
||||||
queryClient.setQueriesData<FetchAggregatedSchedulesState>(
|
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
||||||
{ queryKey: schedulesQueryBaseKey },
|
{ queryKey: schedulesQueryBaseKey },
|
||||||
(current) => updateAggregatedSchedulesData(current, updateSchedules),
|
(current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return { ...current, schedules: updateSchedules(current.schedules) };
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { schedulesQueryKey } from "@/hooks/use-schedules";
|
|
||||||
|
|
||||||
describe("schedulesQueryKey", () => {
|
|
||||||
it("uses only the sorted host identity", () => {
|
|
||||||
expect(schedulesQueryKey(["laptop", "local"])).toEqual(["schedules", "laptop|local"]);
|
|
||||||
expect(schedulesQueryKey(["local", "laptop"])).toEqual(["schedules", "laptop|local"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import { useFetchQuery } from "@/data/query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
getHostRuntimeStore,
|
|
||||||
useHostRuntimeConnectionStatuses,
|
|
||||||
useHosts,
|
|
||||||
} from "@/runtime/host-runtime";
|
|
||||||
import {
|
import {
|
||||||
fetchAggregatedSchedules,
|
fetchAggregatedSchedules,
|
||||||
schedulesQueryBaseKey,
|
|
||||||
type AggregateLoadState,
|
|
||||||
type AggregatedSchedule,
|
type AggregatedSchedule,
|
||||||
type ScheduleHostError,
|
type ScheduleHostError,
|
||||||
type ScheduleHostInput,
|
type ScheduleHostInput,
|
||||||
} from "@/schedules/aggregated-schedules";
|
} from "@/schedules/aggregated-schedules";
|
||||||
|
|
||||||
export type {
|
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
|
||||||
AggregateLoadState,
|
|
||||||
AggregatedSchedule,
|
|
||||||
ScheduleHostError,
|
|
||||||
} from "@/schedules/aggregated-schedules";
|
|
||||||
|
|
||||||
|
export const schedulesQueryBaseKey = ["schedules"] as const;
|
||||||
|
|
||||||
|
// Cache identity for the host set. The query also carries the runtime version
|
||||||
|
// (below) so it retries as connectivity changes and reliably fetches once a host
|
||||||
|
// comes online — even on a cold deep-link. The full-screen spinner flash that
|
||||||
|
// keying on the version used to cause is prevented by keepPreviousData plus the
|
||||||
|
// isInitialLoad(data === undefined) gate, not by dropping the version.
|
||||||
export function schedulesQueryKey(serverIds: readonly string[]) {
|
export function schedulesQueryKey(serverIds: readonly string[]) {
|
||||||
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSchedulesResult {
|
export interface UseSchedulesResult {
|
||||||
loadState: AggregateLoadState<AggregatedSchedule>;
|
schedules: AggregatedSchedule[];
|
||||||
hostErrors: ScheduleHostError[];
|
hostErrors: ScheduleHostError[];
|
||||||
|
isInitialLoad: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
@@ -36,36 +34,27 @@ export interface UseSchedulesResult {
|
|||||||
export function useSchedules(): UseSchedulesResult {
|
export function useSchedules(): UseSchedulesResult {
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
const runtimeVersion = useSyncExternalStore(
|
||||||
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
|
() => runtime.getVersion(),
|
||||||
|
() => runtime.getVersion(),
|
||||||
|
);
|
||||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||||
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
||||||
[hosts],
|
[hosts],
|
||||||
);
|
);
|
||||||
const serverIds = useMemo(() => hostInputs.map((host) => host.serverId), [hostInputs]);
|
|
||||||
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
|
|
||||||
const connectionStatusKey = useMemo(
|
|
||||||
() => serverIds.map((serverId) => connectionStatuses.get(serverId) ?? "connecting").join("|"),
|
|
||||||
[connectionStatuses, serverIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = useFetchQuery({
|
const query = useQuery({
|
||||||
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
|
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
|
||||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||||
dataShape: "list",
|
staleTime: 5_000,
|
||||||
staleTimeMs: 5_000,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
let loadState: AggregateLoadState<AggregatedSchedule>;
|
|
||||||
if (query.data?.status === "connecting") {
|
|
||||||
loadState = { status: "connecting" };
|
|
||||||
} else if (query.data?.status === "loaded") {
|
|
||||||
loadState = { status: "loaded", data: query.data.data };
|
|
||||||
} else {
|
|
||||||
loadState = { status: "loading" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadState,
|
schedules: query.data?.schedules ?? [],
|
||||||
hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [],
|
hostErrors: query.data?.hostErrors ?? [],
|
||||||
|
isInitialLoad: query.isLoading && query.data === undefined,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
refetch: () => {
|
refetch: () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||||
import type { AppLanguage } from "@/i18n/locales";
|
import type { AppLanguage } from "@/i18n/locales";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DESKTOP_SETTINGS,
|
DEFAULT_DESKTOP_SETTINGS,
|
||||||
|
|||||||
@@ -1404,7 +1404,6 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "خلف",
|
backToWorkspace: "خلف",
|
||||||
addHost: "أضف مضيفًا",
|
addHost: "أضف مضيفًا",
|
||||||
enableBuiltInDaemon: "تفعيل البرنامج الخفي المدمج",
|
|
||||||
projects: "المشاريع",
|
projects: "المشاريع",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "تعذر تحميل المشاريع من المضيف{{hostName}}:{{message}}",
|
hostLoadFailed: "تعذر تحميل المشاريع من المضيف{{hostName}}:{{message}}",
|
||||||
@@ -1413,6 +1412,7 @@ export const ar: TranslationResources = {
|
|||||||
groupInfo: "حول{{title}}",
|
groupInfo: "حول{{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "عام",
|
general: "عام",
|
||||||
|
daemon: "Daemon",
|
||||||
appearance: "مظهر",
|
appearance: "مظهر",
|
||||||
shortcuts: "الاختصارات",
|
shortcuts: "الاختصارات",
|
||||||
integrations: "التكامل",
|
integrations: "التكامل",
|
||||||
@@ -1427,7 +1427,7 @@ export const ar: TranslationResources = {
|
|||||||
providers: "مقدمي الخدمات",
|
providers: "مقدمي الخدمات",
|
||||||
usage: "الاستخدام",
|
usage: "الاستخدام",
|
||||||
terminals: "Terminals",
|
terminals: "Terminals",
|
||||||
host: "نظرة عامة",
|
host: "Host",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "عام",
|
title: "عام",
|
||||||
|
|||||||
@@ -1412,7 +1412,6 @@ export const en = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "Back",
|
backToWorkspace: "Back",
|
||||||
addHost: "Add host",
|
addHost: "Add host",
|
||||||
enableBuiltInDaemon: "Enable built-in daemon",
|
|
||||||
projects: "Projects",
|
projects: "Projects",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "Couldn't load projects from host {{hostName}}: {{message}}",
|
hostLoadFailed: "Couldn't load projects from host {{hostName}}: {{message}}",
|
||||||
@@ -1421,6 +1420,7 @@ export const en = {
|
|||||||
groupInfo: "About {{title}}",
|
groupInfo: "About {{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "General",
|
general: "General",
|
||||||
|
daemon: "Daemon",
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
shortcuts: "Shortcuts",
|
shortcuts: "Shortcuts",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
@@ -1435,7 +1435,7 @@ export const en = {
|
|||||||
providers: "Providers",
|
providers: "Providers",
|
||||||
usage: "Usage",
|
usage: "Usage",
|
||||||
terminals: "Terminals",
|
terminals: "Terminals",
|
||||||
host: "Overview",
|
host: "Host",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "General",
|
title: "General",
|
||||||
|
|||||||
@@ -1443,7 +1443,6 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "Atrás",
|
backToWorkspace: "Atrás",
|
||||||
addHost: "Agregar anfitrión",
|
addHost: "Agregar anfitrión",
|
||||||
enableBuiltInDaemon: "Activar el demonio integrado",
|
|
||||||
projects: "Proyectos",
|
projects: "Proyectos",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "No se pudieron cargar proyectos desde el host{{hostName}}:{{message}}",
|
hostLoadFailed: "No se pudieron cargar proyectos desde el host{{hostName}}:{{message}}",
|
||||||
@@ -1452,6 +1451,7 @@ export const es: TranslationResources = {
|
|||||||
groupInfo: "Acerca de{{title}}",
|
groupInfo: "Acerca de{{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "General",
|
general: "General",
|
||||||
|
daemon: "Daemon",
|
||||||
appearance: "Apariencia",
|
appearance: "Apariencia",
|
||||||
shortcuts: "Atajos",
|
shortcuts: "Atajos",
|
||||||
integrations: "Integraciones",
|
integrations: "Integraciones",
|
||||||
@@ -1466,7 +1466,7 @@ export const es: TranslationResources = {
|
|||||||
providers: "Proveedores",
|
providers: "Proveedores",
|
||||||
usage: "Uso",
|
usage: "Uso",
|
||||||
terminals: "Terminals",
|
terminals: "Terminals",
|
||||||
host: "Resumen",
|
host: "Host",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "General",
|
title: "General",
|
||||||
|
|||||||
@@ -1446,7 +1446,6 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "Dos",
|
backToWorkspace: "Dos",
|
||||||
addHost: "Ajouter un hôte",
|
addHost: "Ajouter un hôte",
|
||||||
enableBuiltInDaemon: "Activer le démon intégré",
|
|
||||||
projects: "Projets",
|
projects: "Projets",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}",
|
hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}",
|
||||||
@@ -1455,6 +1454,7 @@ export const fr: TranslationResources = {
|
|||||||
groupInfo: "À propos de{{title}}",
|
groupInfo: "À propos de{{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "Général",
|
general: "Général",
|
||||||
|
daemon: "Daemon",
|
||||||
appearance: "Apparence",
|
appearance: "Apparence",
|
||||||
shortcuts: "Raccourcis",
|
shortcuts: "Raccourcis",
|
||||||
integrations: "Intégrations",
|
integrations: "Intégrations",
|
||||||
@@ -1469,7 +1469,7 @@ export const fr: TranslationResources = {
|
|||||||
providers: "Fournisseurs",
|
providers: "Fournisseurs",
|
||||||
usage: "Utilisation",
|
usage: "Utilisation",
|
||||||
terminals: "Terminals",
|
terminals: "Terminals",
|
||||||
host: "Aperçu",
|
host: "Host",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "Général",
|
title: "Général",
|
||||||
|
|||||||
@@ -1421,7 +1421,6 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "戻る",
|
backToWorkspace: "戻る",
|
||||||
addHost: "ホストを追加",
|
addHost: "ホストを追加",
|
||||||
enableBuiltInDaemon: "組み込みデーモンを有効にする",
|
|
||||||
projects: "プロジェクト",
|
projects: "プロジェクト",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "ホスト{{hostName}}からプロジェクトを読み込めませんでした: {{message}}",
|
hostLoadFailed: "ホスト{{hostName}}からプロジェクトを読み込めませんでした: {{message}}",
|
||||||
@@ -1430,6 +1429,7 @@ export const ja: TranslationResources = {
|
|||||||
groupInfo: "{{title}}について",
|
groupInfo: "{{title}}について",
|
||||||
sections: {
|
sections: {
|
||||||
general: "一般",
|
general: "一般",
|
||||||
|
daemon: "デーモン",
|
||||||
appearance: "外観",
|
appearance: "外観",
|
||||||
shortcuts: "ショートカット",
|
shortcuts: "ショートカット",
|
||||||
integrations: "連携",
|
integrations: "連携",
|
||||||
@@ -1444,7 +1444,7 @@ export const ja: TranslationResources = {
|
|||||||
providers: "プロバイダー",
|
providers: "プロバイダー",
|
||||||
usage: "使用状況",
|
usage: "使用状況",
|
||||||
terminals: "ターミナル",
|
terminals: "ターミナル",
|
||||||
host: "概要",
|
host: "ホスト",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "一般",
|
title: "一般",
|
||||||
|
|||||||
@@ -1429,7 +1429,6 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "Voltar",
|
backToWorkspace: "Voltar",
|
||||||
addHost: "Adicionar host",
|
addHost: "Adicionar host",
|
||||||
enableBuiltInDaemon: "Ativar o daemon integrado",
|
|
||||||
projects: "Projetos",
|
projects: "Projetos",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "Não foi possível carregar projetos do host {{hostName}}: {{message}}",
|
hostLoadFailed: "Não foi possível carregar projetos do host {{hostName}}: {{message}}",
|
||||||
@@ -1438,6 +1437,7 @@ export const ptBR: TranslationResources = {
|
|||||||
groupInfo: "Sobre {{title}}",
|
groupInfo: "Sobre {{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "Geral",
|
general: "Geral",
|
||||||
|
daemon: "Daemon",
|
||||||
appearance: "Aparência",
|
appearance: "Aparência",
|
||||||
shortcuts: "Atalhos",
|
shortcuts: "Atalhos",
|
||||||
integrations: "Integrações",
|
integrations: "Integrações",
|
||||||
@@ -1452,7 +1452,7 @@ export const ptBR: TranslationResources = {
|
|||||||
providers: "Provedores",
|
providers: "Provedores",
|
||||||
usage: "Uso",
|
usage: "Uso",
|
||||||
terminals: "Terminais",
|
terminals: "Terminais",
|
||||||
host: "Visão geral",
|
host: "Host",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "Geral",
|
title: "Geral",
|
||||||
|
|||||||
@@ -1435,7 +1435,6 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "Назад",
|
backToWorkspace: "Назад",
|
||||||
addHost: "Добавить хост",
|
addHost: "Добавить хост",
|
||||||
enableBuiltInDaemon: "Включить встроенный демон",
|
|
||||||
projects: "Проекты",
|
projects: "Проекты",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "Не удалось загрузить проекты с хоста{{hostName}}:{{message}}.",
|
hostLoadFailed: "Не удалось загрузить проекты с хоста{{hostName}}:{{message}}.",
|
||||||
@@ -1444,6 +1443,7 @@ export const ru: TranslationResources = {
|
|||||||
groupInfo: "О{{title}}",
|
groupInfo: "О{{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "Общий",
|
general: "Общий",
|
||||||
|
daemon: "Daemon",
|
||||||
appearance: "Появление",
|
appearance: "Появление",
|
||||||
shortcuts: "Ярлыки",
|
shortcuts: "Ярлыки",
|
||||||
integrations: "Интеграции",
|
integrations: "Интеграции",
|
||||||
@@ -1458,7 +1458,7 @@ export const ru: TranslationResources = {
|
|||||||
providers: "Провайдеры",
|
providers: "Провайдеры",
|
||||||
usage: "Использование",
|
usage: "Использование",
|
||||||
terminals: "Terminals",
|
terminals: "Terminals",
|
||||||
host: "Обзор",
|
host: "Host",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "Общий",
|
title: "Общий",
|
||||||
|
|||||||
@@ -1387,7 +1387,6 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
backToWorkspace: "返回",
|
backToWorkspace: "返回",
|
||||||
addHost: "添加主机",
|
addHost: "添加主机",
|
||||||
enableBuiltInDaemon: "启用内置 daemon",
|
|
||||||
projects: "项目",
|
projects: "项目",
|
||||||
projectList: {
|
projectList: {
|
||||||
hostLoadFailed: "无法从 Host {{hostName}} 加载 projects:{{message}}",
|
hostLoadFailed: "无法从 Host {{hostName}} 加载 projects:{{message}}",
|
||||||
@@ -1396,6 +1395,7 @@ export const zhCN: TranslationResources = {
|
|||||||
groupInfo: "关于 {{title}}",
|
groupInfo: "关于 {{title}}",
|
||||||
sections: {
|
sections: {
|
||||||
general: "通用",
|
general: "通用",
|
||||||
|
daemon: "Daemon",
|
||||||
appearance: "外观",
|
appearance: "外观",
|
||||||
shortcuts: "快捷键",
|
shortcuts: "快捷键",
|
||||||
integrations: "集成",
|
integrations: "集成",
|
||||||
@@ -1410,7 +1410,7 @@ export const zhCN: TranslationResources = {
|
|||||||
providers: "Providers",
|
providers: "Providers",
|
||||||
usage: "使用情况",
|
usage: "使用情况",
|
||||||
terminals: "Terminals",
|
terminals: "Terminals",
|
||||||
host: "概览",
|
host: "Host",
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
title: "通用",
|
title: "通用",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-
|
|||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
|
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||||
|
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||||
|
|
||||||
|
export interface ProjectHostError {
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsRuntimeSnapshot {
|
||||||
|
connectionStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsRuntime {
|
||||||
|
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
|
||||||
|
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsHostInput {
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchAggregatedProjectsInput {
|
||||||
|
hosts: ProjectsHostInput[];
|
||||||
|
runtime: ProjectsRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchAggregatedProjectsResult {
|
||||||
|
projects: ProjectSummary[];
|
||||||
|
hostErrors: ProjectHostError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostWorkspacesResult {
|
||||||
|
host: ProjectHost;
|
||||||
|
error: ProjectHostError | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAggregatedProjects(
|
||||||
|
input: FetchAggregatedProjectsInput,
|
||||||
|
): Promise<FetchAggregatedProjectsResult> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
|
||||||
|
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||||
|
const isOnline = snapshot?.connectionStatus === "online";
|
||||||
|
const client = input.runtime.getClient(host.serverId);
|
||||||
|
|
||||||
|
if (!client || !isOnline) {
|
||||||
|
return {
|
||||||
|
host: {
|
||||||
|
serverId: host.serverId,
|
||||||
|
serverName: host.serverName,
|
||||||
|
isOnline,
|
||||||
|
workspaces: [],
|
||||||
|
emptyProjects: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
|
||||||
|
client,
|
||||||
|
sort: [{ key: "name", direction: "asc" }],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
host: {
|
||||||
|
serverId: host.serverId,
|
||||||
|
serverName: host.serverName,
|
||||||
|
isOnline,
|
||||||
|
workspaces,
|
||||||
|
emptyProjects,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
host: {
|
||||||
|
serverId: host.serverId,
|
||||||
|
serverName: host.serverName,
|
||||||
|
isOnline,
|
||||||
|
workspaces: [],
|
||||||
|
emptyProjects: [],
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
serverId: host.serverId,
|
||||||
|
serverName: host.serverName,
|
||||||
|
message: toErrorMessage(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
|
||||||
|
return {
|
||||||
|
...buildProjects({ hosts: results.map((result) => result.host) }),
|
||||||
|
hostErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -260,25 +260,6 @@ describe("combined model selector data", () => {
|
|||||||
).toBe("Default");
|
).toBe("Default");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => {
|
|
||||||
const providers = buildSelectableProviderSelectorProviders([
|
|
||||||
snapshotEntry({
|
|
||||||
provider: "codex",
|
|
||||||
label: "Codex",
|
|
||||||
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveSelectedModelLabel({
|
|
||||||
providers,
|
|
||||||
selectedProvider: "codex",
|
|
||||||
selectedModel: "gpt-5.3",
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
).toBe("gpt-5.3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps provider snapshot errors visible in the selected trigger label", () => {
|
it("keeps provider snapshot errors visible in the selected trigger label", () => {
|
||||||
const providers = buildSelectableProviderSelectorProviders([
|
const providers = buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({
|
snapshotEntry({
|
||||||
|
|||||||
@@ -177,10 +177,6 @@ export function resolveSelectedModelLabel(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
||||||
const selectedModel = input.selectedModel.trim();
|
|
||||||
if (!model && selectedModel) {
|
|
||||||
return selectedModel;
|
|
||||||
}
|
|
||||||
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
|
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
|
||||||
return (
|
return (
|
||||||
model?.modelLabel ??
|
model?.modelLabel ??
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import {
|
|||||||
buildProviderDefinitionMapForStatuses,
|
buildProviderDefinitionMapForStatuses,
|
||||||
resolveDefaultModel,
|
resolveDefaultModel,
|
||||||
INITIAL_USER_MODIFIED,
|
INITIAL_USER_MODIFIED,
|
||||||
PENDING_AGENT_FORM_RESOLUTION,
|
|
||||||
type AgentFormReducerState,
|
type AgentFormReducerState,
|
||||||
type AgentFormResolutionState,
|
|
||||||
type ProviderModelsByProvider,
|
|
||||||
type UserModifiedFields,
|
type UserModifiedFields,
|
||||||
} from "./resolve-agent-form";
|
} from "./resolve-agent-form";
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
@@ -74,7 +71,6 @@ const bothProviderMap = makeProviderMap(TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFIN
|
|||||||
function makeState(
|
function makeState(
|
||||||
overrides: Partial<AgentFormReducerState["form"]> = {},
|
overrides: Partial<AgentFormReducerState["form"]> = {},
|
||||||
modified: Partial<UserModifiedFields> = {},
|
modified: Partial<UserModifiedFields> = {},
|
||||||
resolution: AgentFormResolutionState = PENDING_AGENT_FORM_RESOLUTION,
|
|
||||||
): AgentFormReducerState {
|
): AgentFormReducerState {
|
||||||
return {
|
return {
|
||||||
form: {
|
form: {
|
||||||
@@ -87,16 +83,9 @@ function makeState(
|
|||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
|
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
|
||||||
resolution,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeProviderModelsByProvider(
|
|
||||||
entries: Array<[AgentProvider, AgentModelDefinition[] | null]>,
|
|
||||||
): ProviderModelsByProvider {
|
|
||||||
return new Map(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("resolveDefaultModel", () => {
|
describe("resolveDefaultModel", () => {
|
||||||
it("returns null for empty or null input", () => {
|
it("returns null for empty or null input", () => {
|
||||||
expect(resolveDefaultModel(null)).toBeNull();
|
expect(resolveDefaultModel(null)).toBeNull();
|
||||||
@@ -716,138 +705,44 @@ describe("resolveFormState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveAgentForm", () => {
|
describe("resolveAgentForm", () => {
|
||||||
describe("resolution state", () => {
|
describe("RESOLVE", () => {
|
||||||
it("requests resolution without changing the current form values", () => {
|
it("applies resolved provider and mode when no user modifications", () => {
|
||||||
const state = makeState(
|
const state = makeState();
|
||||||
{ provider: "codex", modeId: "auto", model: "gpt-5.3-codex" },
|
|
||||||
{ provider: true, model: true },
|
|
||||||
{ status: "completed" },
|
|
||||||
);
|
|
||||||
const next = resolveAgentForm(state, { type: "REQUEST_RESOLUTION" });
|
|
||||||
|
|
||||||
expect(next.form).toEqual(state.form);
|
|
||||||
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
|
||||||
expect(next.resolution.status).toBe("pending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes a pending open resolution when snapshot models arrive late", () => {
|
|
||||||
const state = resolveAgentForm(makeState({ serverId: "host-1" }), {
|
|
||||||
type: "REQUEST_RESOLUTION",
|
|
||||||
});
|
|
||||||
const next = resolveAgentForm(state, {
|
const next = resolveAgentForm(state, {
|
||||||
type: "COMPLETE_RESOLUTION",
|
type: "RESOLVE",
|
||||||
initialValues: undefined,
|
initialValues: undefined,
|
||||||
preferences: {
|
preferences: { provider: "codex" },
|
||||||
provider: "codex",
|
availableModels: null,
|
||||||
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
|
|
||||||
},
|
|
||||||
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
|
|
||||||
allowedProviderMap: codexProviderMap,
|
allowedProviderMap: codexProviderMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next.form.provider).toBe("codex");
|
expect(next.form.provider).toBe("codex");
|
||||||
expect(next.form.modeId).toBe("auto");
|
expect(next.form.modeId).toBe("auto");
|
||||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(next.form.thinkingOptionId).toBe("xhigh");
|
|
||||||
expect(next.resolution.status).toBe("completed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not change settled selection when a background snapshot has different defaults", () => {
|
it("returns the same state reference when nothing changed", () => {
|
||||||
const settled = resolveAgentForm(makeState({ serverId: "host-1" }), {
|
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||||
type: "COMPLETE_RESOLUTION",
|
|
||||||
initialValues: undefined,
|
|
||||||
preferences: {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
|
|
||||||
},
|
|
||||||
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
|
|
||||||
allowedProviderMap: codexProviderMap,
|
|
||||||
});
|
|
||||||
const backgroundModels: AgentModelDefinition[] = [
|
|
||||||
{ provider: "codex", id: "gpt-5.4-codex", label: "gpt-5.4-codex", isDefault: true },
|
|
||||||
];
|
|
||||||
const next = resolveAgentForm(settled, {
|
|
||||||
type: "COMPLETE_RESOLUTION",
|
|
||||||
initialValues: undefined,
|
|
||||||
preferences: {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: { codex: { model: "gpt-5.4-codex" } },
|
|
||||||
},
|
|
||||||
providerModelsByProvider: makeProviderModelsByProvider([["codex", backgroundModels]]),
|
|
||||||
allowedProviderMap: codexProviderMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next).toBe(settled);
|
|
||||||
expect(next.form.provider).toBe("codex");
|
|
||||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefills edit hydration from initial values", () => {
|
|
||||||
const state = makeState({ serverId: "host-1" });
|
|
||||||
const next = resolveAgentForm(state, {
|
const next = resolveAgentForm(state, {
|
||||||
type: "COMPLETE_RESOLUTION",
|
type: "RESOLVE",
|
||||||
initialValues: {
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "full-access",
|
|
||||||
model: "gpt-5.3-codex",
|
|
||||||
thinkingOptionId: "low",
|
|
||||||
workingDir: "/repo",
|
|
||||||
},
|
|
||||||
preferences: { provider: "claude" },
|
|
||||||
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
|
|
||||||
allowedProviderMap: bothProviderMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(next.form.provider).toBe("codex");
|
|
||||||
expect(next.form.modeId).toBe("full-access");
|
|
||||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(next.form.thinkingOptionId).toBe("low");
|
|
||||||
expect(next.form.workingDir).toBe("/repo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a user model change after resolution has completed", () => {
|
|
||||||
const alternateModels: AgentModelDefinition[] = [
|
|
||||||
...CODEX_MODELS,
|
|
||||||
{ provider: "codex", id: "gpt-5.4-codex", label: "gpt-5.4-codex" },
|
|
||||||
];
|
|
||||||
const settled = resolveAgentForm(makeState({ serverId: "host-1" }), {
|
|
||||||
type: "COMPLETE_RESOLUTION",
|
|
||||||
initialValues: undefined,
|
initialValues: undefined,
|
||||||
preferences: {
|
preferences: { provider: "codex" },
|
||||||
provider: "codex",
|
availableModels: null,
|
||||||
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
|
|
||||||
},
|
|
||||||
providerModelsByProvider: makeProviderModelsByProvider([["codex", alternateModels]]),
|
|
||||||
allowedProviderMap: codexProviderMap,
|
|
||||||
});
|
|
||||||
const userChanged = resolveAgentForm(settled, {
|
|
||||||
type: "SET_MODEL_FROM_USER",
|
|
||||||
modelId: "gpt-5.4-codex",
|
|
||||||
availableModels: alternateModels,
|
|
||||||
});
|
|
||||||
const next = resolveAgentForm(userChanged, {
|
|
||||||
type: "COMPLETE_RESOLUTION",
|
|
||||||
initialValues: undefined,
|
|
||||||
preferences: {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
|
|
||||||
},
|
|
||||||
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
|
|
||||||
allowedProviderMap: codexProviderMap,
|
allowedProviderMap: codexProviderMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next).toBe(userChanged);
|
expect(next).toBe(state);
|
||||||
expect(next.form.model).toBe("gpt-5.4-codex");
|
|
||||||
expect(next.userModified.model).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not override user-modified provider while completing", () => {
|
it("does not override user-modified provider", () => {
|
||||||
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
|
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
|
||||||
const next = resolveAgentForm(state, {
|
const next = resolveAgentForm(state, {
|
||||||
type: "COMPLETE_RESOLUTION",
|
type: "RESOLVE",
|
||||||
initialValues: undefined,
|
initialValues: undefined,
|
||||||
preferences: { provider: "claude" },
|
preferences: { provider: "claude" },
|
||||||
providerModelsByProvider: makeProviderModelsByProvider([]),
|
availableModels: null,
|
||||||
|
|
||||||
allowedProviderMap: bothProviderMap,
|
allowedProviderMap: bothProviderMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1081,13 +976,11 @@ describe("resolveAgentForm", () => {
|
|||||||
const state = makeState(
|
const state = makeState(
|
||||||
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
|
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
|
||||||
{ provider: true, modeId: true, model: true },
|
{ provider: true, modeId: true, model: true },
|
||||||
{ status: "completed" },
|
|
||||||
);
|
);
|
||||||
const next = resolveAgentForm(state, { type: "RESET" });
|
const next = resolveAgentForm(state, { type: "RESET" });
|
||||||
|
|
||||||
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
||||||
expect(next.form).toEqual(state.form);
|
expect(next.form).toEqual(state.form);
|
||||||
expect(next.resolution.status).toBe("completed");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,9 @@ export interface UserModifiedFields {
|
|||||||
workingDir: boolean;
|
workingDir: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderModelsByProvider = Map<AgentProvider, AgentModelDefinition[] | null>;
|
|
||||||
|
|
||||||
export type AgentFormResolutionState = { status: "pending" } | { status: "completed" };
|
|
||||||
|
|
||||||
export interface AgentFormReducerState {
|
export interface AgentFormReducerState {
|
||||||
form: FormState;
|
form: FormState;
|
||||||
userModified: UserModifiedFields;
|
userModified: UserModifiedFields;
|
||||||
resolution: AgentFormResolutionState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||||
@@ -56,9 +51,6 @@ export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
|||||||
workingDir: false,
|
workingDir: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const INITIAL_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "completed" };
|
|
||||||
export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" };
|
|
||||||
|
|
||||||
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
||||||
|
|
||||||
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
|
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
|
||||||
@@ -68,12 +60,11 @@ export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["statu
|
|||||||
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||||
|
|
||||||
export type AgentFormAction =
|
export type AgentFormAction =
|
||||||
| { type: "REQUEST_RESOLUTION" }
|
|
||||||
| {
|
| {
|
||||||
type: "COMPLETE_RESOLUTION";
|
type: "RESOLVE";
|
||||||
initialValues: FormInitialValues | undefined;
|
initialValues: FormInitialValues | undefined;
|
||||||
preferences: FormPreferences | null;
|
preferences: FormPreferences | null;
|
||||||
providerModelsByProvider: ProviderModelsByProvider;
|
availableModels: AgentModelDefinition[] | null;
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
}
|
}
|
||||||
| { type: "SET_SERVER_ID"; value: string | null }
|
| { type: "SET_SERVER_ID"; value: string | null }
|
||||||
@@ -106,8 +97,6 @@ export type AgentFormAction =
|
|||||||
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
|
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
|
||||||
| { type: "RESET" };
|
| { type: "RESET" };
|
||||||
|
|
||||||
type CompleteResolutionAction = Extract<AgentFormAction, { type: "COMPLETE_RESOLUTION" }>;
|
|
||||||
|
|
||||||
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
return typeof modelId === "string" ? modelId.trim() : "";
|
return typeof modelId === "string" ? modelId.trim() : "";
|
||||||
}
|
}
|
||||||
@@ -415,36 +404,6 @@ export function resolveFormState(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFormStateFromProviderModels(
|
|
||||||
initialValues: FormInitialValues | undefined,
|
|
||||||
preferences: FormPreferences | null,
|
|
||||||
providerModelsByProvider: ProviderModelsByProvider,
|
|
||||||
userModified: UserModifiedFields,
|
|
||||||
currentState: FormState,
|
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
|
||||||
): FormState {
|
|
||||||
const providerResolved = resolveFormState(
|
|
||||||
initialValues,
|
|
||||||
preferences,
|
|
||||||
null,
|
|
||||||
userModified,
|
|
||||||
currentState,
|
|
||||||
allowedProviderMap,
|
|
||||||
);
|
|
||||||
const availableModels = providerResolved.provider
|
|
||||||
? (providerModelsByProvider.get(providerResolved.provider) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return resolveFormState(
|
|
||||||
initialValues,
|
|
||||||
preferences,
|
|
||||||
availableModels,
|
|
||||||
userModified,
|
|
||||||
currentState,
|
|
||||||
allowedProviderMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextModelForProvider(input: {
|
function pickNextModelForProvider(input: {
|
||||||
providerModels: AgentModelDefinition[] | null;
|
providerModels: AgentModelDefinition[] | null;
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
@@ -501,47 +460,29 @@ function pickNextThinkingOptionForProvider(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeResolution(
|
|
||||||
state: AgentFormReducerState,
|
|
||||||
action: CompleteResolutionAction,
|
|
||||||
): AgentFormReducerState {
|
|
||||||
if (state.resolution.status === "completed") {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const resolved = resolveFormStateFromProviderModels(
|
|
||||||
action.initialValues,
|
|
||||||
action.preferences,
|
|
||||||
action.providerModelsByProvider,
|
|
||||||
state.userModified,
|
|
||||||
state.form,
|
|
||||||
action.allowedProviderMap,
|
|
||||||
);
|
|
||||||
const nextState = { ...state, resolution: { status: "completed" } as const };
|
|
||||||
if (!hasFormStateChanged(state.form, resolved)) return nextState;
|
|
||||||
return { ...nextState, form: resolved };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAgentForm(
|
export function resolveAgentForm(
|
||||||
state: AgentFormReducerState,
|
state: AgentFormReducerState,
|
||||||
action: AgentFormAction,
|
action: AgentFormAction,
|
||||||
): AgentFormReducerState {
|
): AgentFormReducerState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "REQUEST_RESOLUTION":
|
case "RESOLVE": {
|
||||||
return {
|
const resolved = resolveFormState(
|
||||||
...state,
|
action.initialValues,
|
||||||
userModified: INITIAL_USER_MODIFIED,
|
action.preferences,
|
||||||
resolution: PENDING_AGENT_FORM_RESOLUTION,
|
action.availableModels,
|
||||||
};
|
state.userModified,
|
||||||
|
state.form,
|
||||||
case "COMPLETE_RESOLUTION":
|
action.allowedProviderMap,
|
||||||
return completeResolution(state, action);
|
);
|
||||||
|
if (!hasFormStateChanged(state.form, resolved)) return state;
|
||||||
|
return { ...state, form: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
case "SET_SERVER_ID":
|
case "SET_SERVER_ID":
|
||||||
return { ...state, form: { ...state.form, serverId: action.value } };
|
return { ...state, form: { ...state.form, serverId: action.value } };
|
||||||
|
|
||||||
case "SET_SERVER_ID_FROM_USER":
|
case "SET_SERVER_ID_FROM_USER":
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: { ...state.form, serverId: action.value },
|
form: { ...state.form, serverId: action.value },
|
||||||
userModified: { ...state.userModified, serverId: true },
|
userModified: { ...state.userModified, serverId: true },
|
||||||
};
|
};
|
||||||
@@ -561,7 +502,6 @@ export function resolveAgentForm(
|
|||||||
modelId: nextModelId,
|
modelId: nextModelId,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: {
|
form: {
|
||||||
...state.form,
|
...state.form,
|
||||||
provider: action.provider,
|
provider: action.provider,
|
||||||
@@ -589,7 +529,6 @@ export function resolveAgentForm(
|
|||||||
providerPrefs: action.providerPrefs,
|
providerPrefs: action.providerPrefs,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: {
|
form: {
|
||||||
...state.form,
|
...state.form,
|
||||||
provider: action.provider,
|
provider: action.provider,
|
||||||
@@ -603,7 +542,6 @@ export function resolveAgentForm(
|
|||||||
|
|
||||||
case "SET_MODE_FROM_USER":
|
case "SET_MODE_FROM_USER":
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: { ...state.form, modeId: action.modeId },
|
form: { ...state.form, modeId: action.modeId },
|
||||||
userModified: { ...state.userModified, modeId: true },
|
userModified: { ...state.userModified, modeId: true },
|
||||||
};
|
};
|
||||||
@@ -619,7 +557,6 @@ export function resolveAgentForm(
|
|||||||
: "",
|
: "",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: {
|
form: {
|
||||||
...state.form,
|
...state.form,
|
||||||
model: nextModelId,
|
model: nextModelId,
|
||||||
@@ -631,7 +568,6 @@ export function resolveAgentForm(
|
|||||||
|
|
||||||
case "CLEAR_PROVIDER_SELECTION_FROM_USER":
|
case "CLEAR_PROVIDER_SELECTION_FROM_USER":
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: {
|
form: {
|
||||||
...state.form,
|
...state.form,
|
||||||
provider: null,
|
provider: null,
|
||||||
@@ -650,7 +586,6 @@ export function resolveAgentForm(
|
|||||||
|
|
||||||
case "SET_THINKING_OPTION_FROM_USER":
|
case "SET_THINKING_OPTION_FROM_USER":
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||||
userModified: { ...state.userModified, thinkingOptionId: true },
|
userModified: { ...state.userModified, thinkingOptionId: true },
|
||||||
};
|
};
|
||||||
@@ -660,7 +595,6 @@ export function resolveAgentForm(
|
|||||||
|
|
||||||
case "SET_WORKING_DIR_FROM_USER":
|
case "SET_WORKING_DIR_FROM_USER":
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
form: { ...state.form, workingDir: action.value },
|
form: { ...state.form, workingDir: action.value },
|
||||||
userModified: { ...state.userModified, workingDir: true },
|
userModified: { ...state.userModified, workingDir: true },
|
||||||
};
|
};
|
||||||
@@ -670,11 +604,7 @@ export function resolveAgentForm(
|
|||||||
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
|
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
|
||||||
|
|
||||||
case "RESET":
|
case "RESET":
|
||||||
return {
|
return { ...state, userModified: INITIAL_USER_MODIFIED };
|
||||||
...state,
|
|
||||||
userModified: INITIAL_USER_MODIFIED,
|
|
||||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1775,41 +1775,6 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves a manual host rename when desktop status re-advertises the daemon hostname", async () => {
|
|
||||||
const advertisedHostname = "macbook-pro.local";
|
|
||||||
const store = new HostRuntimeStore({
|
|
||||||
deps: {
|
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
|
||||||
connectToDaemon: async ({ host }) => ({
|
|
||||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
|
||||||
serverId: host.serverId,
|
|
||||||
hostname: advertisedHostname,
|
|
||||||
}),
|
|
||||||
getClientId: async () => "cid_test_runtime",
|
|
||||||
},
|
|
||||||
storage: createMemoryHostRuntimeStorage(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.upsertConnectionFromListen({
|
|
||||||
listenAddress: "127.0.0.1:6767",
|
|
||||||
serverId: "srv_desktop",
|
|
||||||
hostname: advertisedHostname,
|
|
||||||
});
|
|
||||||
await store.renameHost("srv_desktop", "mac-dev");
|
|
||||||
|
|
||||||
await store.upsertConnectionFromListen({
|
|
||||||
listenAddress: "127.0.0.1:6767",
|
|
||||||
serverId: "srv_desktop",
|
|
||||||
hostname: advertisedHostname,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(store.getHosts().find((h) => h.serverId === "srv_desktop")?.label).toBe("mac-dev");
|
|
||||||
} finally {
|
|
||||||
store.syncHosts([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upsertDirectConnection stores SSL and password settings", async () => {
|
it("upsertDirectConnection stores SSL and password settings", async () => {
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
@@ -2017,7 +1982,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
@@ -2028,7 +1993,6 @@ describe("HostRuntimeStore", () => {
|
|||||||
}),
|
}),
|
||||||
getClientId: async () => "cid_test_runtime",
|
getClientId: async () => "cid_test_runtime",
|
||||||
},
|
},
|
||||||
storage: createMemoryHostRuntimeStorage(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.upsertRelayConnection({
|
await store.upsertRelayConnection({
|
||||||
@@ -2041,7 +2005,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||||
|
|
||||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||||
expect(pairedHost?.label).toBe("Custom name");
|
expect(pairedHost?.label).toBe("mbp");
|
||||||
|
|
||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,13 +47,8 @@ import {
|
|||||||
shouldUseLegacyDaemonWorkspaceDirectory,
|
shouldUseLegacyDaemonWorkspaceDirectory,
|
||||||
} from "@/workspace/legacy-daemon-workspaces";
|
} from "@/workspace/legacy-daemon-workspaces";
|
||||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||||
import { queryClient } from "@/data/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
|
||||||
invalidateServerDataQueriesAfterReconnect,
|
|
||||||
mountServerDataPushRouter,
|
|
||||||
} from "@/data/push-router";
|
|
||||||
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
|
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
|
||||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
|
||||||
|
|
||||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||||
export type HostRegistryStatus = "loading" | "ready";
|
export type HostRegistryStatus = "loading" | "ready";
|
||||||
@@ -590,21 +585,10 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
}),
|
}),
|
||||||
getClientId: () => getOrCreateClientId(),
|
getClientId: () => getOrCreateClientId(),
|
||||||
mountClientHandlers: ({ client, host }) => {
|
mountClientHandlers: ({ client, host }) => {
|
||||||
const unmountServerData = mountServerDataPushRouter({
|
|
||||||
client,
|
|
||||||
queryClient,
|
|
||||||
serverId: host.serverId,
|
|
||||||
});
|
|
||||||
if (!browserAutomationCapabilities) {
|
if (!browserAutomationCapabilities) {
|
||||||
return unmountServerData;
|
return () => {};
|
||||||
}
|
}
|
||||||
const unmountBrowserAutomation = mountBrowserAutomationDaemonClientHandler(client, {
|
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
|
||||||
serverId: host.serverId,
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unmountBrowserAutomation();
|
|
||||||
unmountServerData();
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1990,8 +1974,6 @@ export class HostRuntimeStore {
|
|||||||
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
|
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
|
||||||
// queries refetch now and evicted ones on their next mount.
|
// queries refetch now and evicted ones on their next mount.
|
||||||
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
|
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
|
||||||
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
fetchAggregatedSchedules,
|
|
||||||
type ScheduleRuntime,
|
|
||||||
type ScheduleRuntimeSnapshot,
|
|
||||||
} from "./aggregated-schedules";
|
|
||||||
|
|
||||||
function makeSchedule(overrides: Partial<ScheduleSummary> = {}): ScheduleSummary {
|
|
||||||
return {
|
|
||||||
id: "schedule-1",
|
|
||||||
name: "Nightly",
|
|
||||||
prompt: "Run the task",
|
|
||||||
cadence: { type: "every", everyMs: 60_000 },
|
|
||||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
|
||||||
status: "active",
|
|
||||||
createdAt: "2026-07-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
|
||||||
nextRunAt: "2026-07-02T01:00:00.000Z",
|
|
||||||
lastRunAt: null,
|
|
||||||
pausedAt: null,
|
|
||||||
expiresAt: null,
|
|
||||||
maxRuns: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRuntime(input: {
|
|
||||||
snapshots: Record<string, ScheduleRuntimeSnapshot | null>;
|
|
||||||
schedules?: Record<string, ScheduleSummary[]>;
|
|
||||||
}): ScheduleRuntime {
|
|
||||||
return {
|
|
||||||
getSnapshot: (serverId) => input.snapshots[serverId] ?? null,
|
|
||||||
getClient: (serverId) => {
|
|
||||||
const schedules = input.schedules?.[serverId];
|
|
||||||
if (!schedules) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
scheduleList: async () => ({ requestId: "test-request", schedules, error: null }),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("fetchAggregatedSchedules load state", () => {
|
|
||||||
it("does not report loaded empty while known hosts are still connecting", async () => {
|
|
||||||
const result = await fetchAggregatedSchedules({
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-a", serverName: "Host A" },
|
|
||||||
{ serverId: "host-b", serverName: "Host B" },
|
|
||||||
],
|
|
||||||
runtime: makeRuntime({
|
|
||||||
snapshots: {
|
|
||||||
"host-a": { connectionStatus: "connecting" },
|
|
||||||
"host-b": { connectionStatus: "connecting" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.status).not.toBe("loaded");
|
|
||||||
expect(result).toEqual({ status: "connecting" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports loaded empty after all reachable hosts answer with no schedules", async () => {
|
|
||||||
const result = await fetchAggregatedSchedules({
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-a", serverName: "Host A" },
|
|
||||||
{ serverId: "host-b", serverName: "Host B" },
|
|
||||||
],
|
|
||||||
runtime: makeRuntime({
|
|
||||||
snapshots: {
|
|
||||||
"host-a": { connectionStatus: "online" },
|
|
||||||
"host-b": { connectionStatus: "online" },
|
|
||||||
},
|
|
||||||
schedules: {
|
|
||||||
"host-a": [],
|
|
||||||
"host-b": [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ status: "loaded", data: [], hostErrors: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not report loaded empty while another known host is still connecting", async () => {
|
|
||||||
const result = await fetchAggregatedSchedules({
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-a", serverName: "Host A" },
|
|
||||||
{ serverId: "host-b", serverName: "Host B" },
|
|
||||||
],
|
|
||||||
runtime: makeRuntime({
|
|
||||||
snapshots: {
|
|
||||||
"host-a": { connectionStatus: "online" },
|
|
||||||
"host-b": { connectionStatus: "connecting" },
|
|
||||||
},
|
|
||||||
schedules: {
|
|
||||||
"host-a": [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ status: "connecting" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads reachable host data when another known host is still connecting", async () => {
|
|
||||||
const schedule = makeSchedule();
|
|
||||||
const result = await fetchAggregatedSchedules({
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-a", serverName: "Host A" },
|
|
||||||
{ serverId: "host-b", serverName: "Host B" },
|
|
||||||
],
|
|
||||||
runtime: makeRuntime({
|
|
||||||
snapshots: {
|
|
||||||
"host-a": { connectionStatus: "online" },
|
|
||||||
"host-b": { connectionStatus: "connecting" },
|
|
||||||
},
|
|
||||||
schedules: {
|
|
||||||
"host-a": [schedule],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
status: "loaded",
|
|
||||||
data: [{ ...schedule, serverId: "host-a", serverName: "Host A" }],
|
|
||||||
hostErrors: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,8 +2,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
|
||||||
export const schedulesQueryBaseKey = ["schedules"] as const;
|
|
||||||
|
|
||||||
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
|
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
|
||||||
|
|
||||||
export interface ScheduleHostInput {
|
export interface ScheduleHostInput {
|
||||||
@@ -33,25 +31,11 @@ export interface ScheduleHostError {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesConnectingResult {
|
|
||||||
status: "connecting";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesResult {
|
export interface FetchAggregatedSchedulesResult {
|
||||||
status: "loaded";
|
schedules: AggregatedSchedule[];
|
||||||
data: AggregatedSchedule[];
|
|
||||||
hostErrors: ScheduleHostError[];
|
hostErrors: ScheduleHostError[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FetchAggregatedSchedulesState =
|
|
||||||
| FetchAggregatedSchedulesConnectingResult
|
|
||||||
| FetchAggregatedSchedulesResult;
|
|
||||||
|
|
||||||
export type AggregateLoadState<T> =
|
|
||||||
| { status: "connecting" }
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; data: T[] };
|
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesInput {
|
export interface FetchAggregatedSchedulesInput {
|
||||||
hosts: readonly ScheduleHostInput[];
|
hosts: readonly ScheduleHostInput[];
|
||||||
runtime: ScheduleRuntime;
|
runtime: ScheduleRuntime;
|
||||||
@@ -59,8 +43,9 @@ export interface FetchAggregatedSchedulesInput {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch schedules across connected hosts and merge them into one flat list.
|
* Fetch schedules across connected hosts and merge them into one flat list.
|
||||||
* Connectivity is checked here at execution time, so explicit query refreshes
|
* Connectivity is checked here at execution time (not pre-filtered by the hook)
|
||||||
* pick up the currently connected host set.
|
* so the query — retried as the runtime version changes — reliably picks a host
|
||||||
|
* up the moment it comes online, including on a cold deep-link.
|
||||||
*
|
*
|
||||||
* Offline hosts are skipped. A connected host that fails contributes to
|
* Offline hosts are skipped. A connected host that fails contributes to
|
||||||
* `hostErrors` (surfaced as a banner) while the rest still render; only when
|
* `hostErrors` (surfaced as a banner) while the rest still render; only when
|
||||||
@@ -68,19 +53,7 @@ export interface FetchAggregatedSchedulesInput {
|
|||||||
*/
|
*/
|
||||||
export async function fetchAggregatedSchedules(
|
export async function fetchAggregatedSchedules(
|
||||||
input: FetchAggregatedSchedulesInput,
|
input: FetchAggregatedSchedulesInput,
|
||||||
): Promise<FetchAggregatedSchedulesState> {
|
): Promise<FetchAggregatedSchedulesResult> {
|
||||||
const hasSettlingHost = input.hosts.some((host) =>
|
|
||||||
isScheduleHostConnectionSettling(input.runtime.getSnapshot(host.serverId)),
|
|
||||||
);
|
|
||||||
const hasAskableHost = input.hosts.some((host) => {
|
|
||||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
|
||||||
return snapshot?.connectionStatus === "online" && input.runtime.getClient(host.serverId);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasAskableHost && hasSettlingHost) {
|
|
||||||
return { status: "connecting" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const schedules: AggregatedSchedule[] = [];
|
const schedules: AggregatedSchedule[] = [];
|
||||||
const hostErrors: ScheduleHostError[] = [];
|
const hostErrors: ScheduleHostError[] = [];
|
||||||
let connectedAttempts = 0;
|
let connectedAttempts = 0;
|
||||||
@@ -116,18 +89,5 @@ export async function fetchAggregatedSchedules(
|
|||||||
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
|
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schedules.length === 0 && hasSettlingHost) {
|
return { schedules, hostErrors };
|
||||||
return { status: "connecting" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: "loaded", data: schedules, hostErrors };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isScheduleHostConnectionSettling(
|
|
||||||
snapshot: ScheduleRuntimeSnapshot | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
if (!snapshot) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return snapshot.connectionStatus === "connecting" || snapshot.connectionStatus === "idle";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
CADENCE_PRESET_OPTIONS,
|
|
||||||
normalizeScheduleFormCadence,
|
|
||||||
resolveCronPresetId,
|
|
||||||
} from "./schedule-cadence-options";
|
|
||||||
|
|
||||||
describe("schedule cadence form options", () => {
|
|
||||||
it("offers the approved cron preset vocabulary", () => {
|
|
||||||
expect(CADENCE_PRESET_OPTIONS.map((option) => option.label)).toEqual([
|
|
||||||
"Every minute",
|
|
||||||
"Every hour",
|
|
||||||
"Daily 9:00",
|
|
||||||
"Weekdays 9:00",
|
|
||||||
"Mondays 9:00",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps interval cadences to cron cadences for the form", () => {
|
|
||||||
expect(
|
|
||||||
normalizeScheduleFormCadence({ type: "every", everyMs: 60_000 }, "Europe/Madrid"),
|
|
||||||
).toEqual({
|
|
||||||
type: "cron",
|
|
||||||
expression: "* * * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
normalizeScheduleFormCadence({ type: "every", everyMs: 60 * 60_000 }, "Europe/Madrid"),
|
|
||||||
).toEqual({
|
|
||||||
type: "cron",
|
|
||||||
expression: "0 * * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
normalizeScheduleFormCadence({ type: "every", everyMs: 24 * 60 * 60_000 }, "Europe/Madrid"),
|
|
||||||
).toEqual({
|
|
||||||
type: "cron",
|
|
||||||
expression: "0 9 * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps unsupported intervals to the closest custom cron expression", () => {
|
|
||||||
const cadence = normalizeScheduleFormCadence(
|
|
||||||
{ type: "every", everyMs: 5 * 60_000 },
|
|
||||||
"Europe/Madrid",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(cadence).toEqual({
|
|
||||||
type: "cron",
|
|
||||||
expression: "*/5 * * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
expect(resolveCronPresetId(cadence)).toBe("custom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
|
||||||
import { everyMsToParts } from "@/utils/schedule-format";
|
|
||||||
|
|
||||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
|
||||||
|
|
||||||
export interface CadencePresetOption {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
expression: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CUSTOM_CRON_PRESET_ID = "custom";
|
|
||||||
|
|
||||||
export const CADENCE_PRESET_OPTIONS: CadencePresetOption[] = [
|
|
||||||
{ id: "every-minute", label: "Every minute", expression: "* * * * *" },
|
|
||||||
{ id: "every-hour", label: "Every hour", expression: "0 * * * *" },
|
|
||||||
{ id: "daily-9", label: "Daily 9:00", expression: "0 9 * * *" },
|
|
||||||
{ id: "weekdays-9", label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
|
|
||||||
{ id: "mondays-9", label: "Mondays 9:00", expression: "0 9 * * 1" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function resolveCronPresetId(cadence: CronCadence): string {
|
|
||||||
const expression = cadence.expression.trim();
|
|
||||||
return (
|
|
||||||
CADENCE_PRESET_OPTIONS.find((option) => option.expression === expression)?.id ??
|
|
||||||
CUSTOM_CRON_PRESET_ID
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveCronPresetDisplay(cadence: CronCadence): { label: string } {
|
|
||||||
return {
|
|
||||||
label:
|
|
||||||
CADENCE_PRESET_OPTIONS.find((option) => option.id === resolveCronPresetId(cadence))?.label ??
|
|
||||||
"Custom cron",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeScheduleFormCadence(
|
|
||||||
cadence: ScheduleCadence,
|
|
||||||
timezone: string,
|
|
||||||
): CronCadence {
|
|
||||||
if (cadence.type === "cron") {
|
|
||||||
return { ...cadence, timezone: cadence.timezone ?? timezone };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "cron",
|
|
||||||
expression: everyMsToCronExpression(cadence.everyMs),
|
|
||||||
timezone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function everyMsToCronExpression(everyMs: number): string {
|
|
||||||
const { value, unit } = everyMsToParts(everyMs);
|
|
||||||
if (unit === "minutes") {
|
|
||||||
return value === 1 ? "* * * * *" : `*/${Math.min(value, 59)} * * * *`;
|
|
||||||
}
|
|
||||||
if (unit === "hours") {
|
|
||||||
return value === 1 ? "0 * * * *" : `0 */${Math.min(value, 23)} * * *`;
|
|
||||||
}
|
|
||||||
return value === 1 ? "0 9 * * *" : `0 9 */${Math.min(value, 31)} * *`;
|
|
||||||
}
|
|
||||||
@@ -1,612 +0,0 @@
|
|||||||
import type {
|
|
||||||
AgentMode,
|
|
||||||
AgentModelDefinition,
|
|
||||||
ProviderSnapshotEntry,
|
|
||||||
} from "@getpaseo/protocol/agent-types";
|
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets";
|
|
||||||
import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model";
|
|
||||||
|
|
||||||
type TestSchedule = ScheduleSummary & { serverId: string; serverName: string };
|
|
||||||
|
|
||||||
const HOSTS = [
|
|
||||||
{ serverId: "host-a", label: "Host A", supportsWorkspaceMultiplicity: true },
|
|
||||||
{ serverId: "host-b", label: "Host B", supportsWorkspaceMultiplicity: true },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const MOCK_MODES: AgentMode[] = [{ id: "load-test", label: "Load test" }];
|
|
||||||
|
|
||||||
const HOST_A_MODELS: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "mock",
|
|
||||||
id: "model-a",
|
|
||||||
label: "Model A",
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const HOST_B_MODELS: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "mock",
|
|
||||||
id: "model-b",
|
|
||||||
label: "Model B",
|
|
||||||
isDefault: true,
|
|
||||||
defaultThinkingOptionId: "high",
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "high", label: "High", isDefault: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS];
|
|
||||||
|
|
||||||
function target(input: {
|
|
||||||
serverId: string;
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
isGit?: boolean;
|
|
||||||
}): ScheduleProjectTarget {
|
|
||||||
return {
|
|
||||||
optionId: buildProjectOptionId(input.serverId, input.projectKey),
|
|
||||||
serverId: input.serverId,
|
|
||||||
serverName: input.serverId === "host-a" ? "Host A" : "Host B",
|
|
||||||
projectKey: input.projectKey,
|
|
||||||
projectName: input.projectName,
|
|
||||||
cwd: input.cwd,
|
|
||||||
isGit: input.isGit ?? true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROJECT_TARGETS = [
|
|
||||||
target({
|
|
||||||
serverId: "host-a",
|
|
||||||
projectKey: "project-a",
|
|
||||||
projectName: "Project A",
|
|
||||||
cwd: "/repo/a",
|
|
||||||
}),
|
|
||||||
target({
|
|
||||||
serverId: "host-b",
|
|
||||||
projectKey: "project-b",
|
|
||||||
projectName: "Project B",
|
|
||||||
cwd: "/repo/b",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
function scheduleOnHost(input: {
|
|
||||||
serverId: string;
|
|
||||||
serverName: string;
|
|
||||||
cwd: string;
|
|
||||||
model: string;
|
|
||||||
cadence?: ScheduleSummary["cadence"];
|
|
||||||
}): TestSchedule {
|
|
||||||
return {
|
|
||||||
id: `schedule-${input.serverId}`,
|
|
||||||
serverId: input.serverId,
|
|
||||||
serverName: input.serverName,
|
|
||||||
name: "Nightly",
|
|
||||||
prompt: "Run the schedule",
|
|
||||||
cadence: input.cadence ?? { type: "cron", expression: "0 9 * * *" },
|
|
||||||
target: {
|
|
||||||
type: "new-agent",
|
|
||||||
config: {
|
|
||||||
provider: "mock",
|
|
||||||
cwd: input.cwd,
|
|
||||||
model: input.model,
|
|
||||||
modeId: "load-test",
|
|
||||||
thinkingOptionId: "high",
|
|
||||||
archiveOnFinish: false,
|
|
||||||
isolation: "worktree",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
createdAt: "2026-07-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
|
||||||
nextRunAt: "2026-07-02T00:00:00.000Z",
|
|
||||||
lastRunAt: null,
|
|
||||||
pausedAt: null,
|
|
||||||
expiresAt: null,
|
|
||||||
maxRuns: 3,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
|
|
||||||
return {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
provider: "mock",
|
|
||||||
label: "Mock",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
fetchedAt: "2026-07-01T00:00:00.000Z",
|
|
||||||
models,
|
|
||||||
modes: MOCK_MODES,
|
|
||||||
defaultModeId: "load-test",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function open(snapshot: Omit<ScheduleFormSnapshot, "hosts">) {
|
|
||||||
return openScheduleForm({
|
|
||||||
...snapshot,
|
|
||||||
hosts: HOSTS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openWithHosts(snapshot: ScheduleFormSnapshot) {
|
|
||||||
return openScheduleForm(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPreferences(form: ReturnType<typeof open>, preferences: FormPreferences) {
|
|
||||||
form.applyPreferences(preferences);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("schedule form model", () => {
|
|
||||||
it("opens edit from the schedule host snapshot and completes that host resolution", () => {
|
|
||||||
const previous = open({
|
|
||||||
mode: "edit",
|
|
||||||
schedule: scheduleOnHost({
|
|
||||||
serverId: "host-a",
|
|
||||||
serverName: "Host A",
|
|
||||||
cwd: "/repo/a",
|
|
||||||
model: "model-a",
|
|
||||||
}),
|
|
||||||
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
previous.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS));
|
|
||||||
previous.close();
|
|
||||||
|
|
||||||
const form = open({
|
|
||||||
mode: "edit",
|
|
||||||
schedule: scheduleOnHost({
|
|
||||||
serverId: "host-b",
|
|
||||||
serverName: "Host B",
|
|
||||||
cwd: "/repo/b",
|
|
||||||
model: "model-b",
|
|
||||||
}),
|
|
||||||
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
mode: "edit",
|
|
||||||
selectedServerId: "host-b",
|
|
||||||
workingDir: "/repo/b",
|
|
||||||
selectedProvider: "mock",
|
|
||||||
selectedModel: "model-b",
|
|
||||||
selectedMode: "load-test",
|
|
||||||
selectedThinkingOptionId: "high",
|
|
||||||
projectDisplay: { label: "Project B" },
|
|
||||||
selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"),
|
|
||||||
archiveOnFinish: false,
|
|
||||||
isolation: "worktree",
|
|
||||||
effectiveIsolation: "worktree",
|
|
||||||
providerResolutionByServerId: { "host-b": "pending" },
|
|
||||||
providerSnapshotRequest: {
|
|
||||||
serverId: "host-b",
|
|
||||||
cwd: "/repo/b",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
selectedServerId: "host-b",
|
|
||||||
selectedModelDisplay: { label: "Model B" },
|
|
||||||
selectedModeDisplay: { label: "Load test" },
|
|
||||||
selectedThinkingDisplay: { label: "High" },
|
|
||||||
providerResolutionByServerId: { "host-b": "complete" },
|
|
||||||
providerSnapshotRequest: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens create pristine after an edit instance closes", () => {
|
|
||||||
const edit = open({
|
|
||||||
mode: "edit",
|
|
||||||
schedule: scheduleOnHost({
|
|
||||||
serverId: "host-b",
|
|
||||||
serverName: "Host B",
|
|
||||||
cwd: "/repo/b",
|
|
||||||
model: "model-b",
|
|
||||||
}),
|
|
||||||
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
edit.close();
|
|
||||||
|
|
||||||
const create = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(create.getState()).toMatchObject({
|
|
||||||
mode: "create",
|
|
||||||
selectedServerId: "host-a",
|
|
||||||
workingDir: "",
|
|
||||||
selectedProvider: null,
|
|
||||||
selectedModel: "",
|
|
||||||
selectedMode: "",
|
|
||||||
selectedThinkingOptionId: "",
|
|
||||||
projectDisplay: null,
|
|
||||||
selectedProjectOptionId: "",
|
|
||||||
selectedModelDisplay: null,
|
|
||||||
selectedThinkingDisplay: null,
|
|
||||||
archiveOnFinish: true,
|
|
||||||
isolation: "local",
|
|
||||||
effectiveIsolation: "local",
|
|
||||||
providerResolutionByServerId: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives the host to project to model disclosure chain from model state", () => {
|
|
||||||
const form = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(form.getState().disclosure).toEqual({
|
|
||||||
showProjectField: true,
|
|
||||||
showModelField: false,
|
|
||||||
showThinkingField: false,
|
|
||||||
showModeField: false,
|
|
||||||
showIsolationField: false,
|
|
||||||
showArchiveOnFinishField: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
|
|
||||||
|
|
||||||
expect(form.getState().disclosure).toEqual({
|
|
||||||
showProjectField: true,
|
|
||||||
showModelField: true,
|
|
||||||
showThinkingField: false,
|
|
||||||
showModeField: false,
|
|
||||||
showIsolationField: true,
|
|
||||||
showArchiveOnFinishField: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.applyProviderSnapshot("host-a", providerSnapshot(HOST_B_MODELS));
|
|
||||||
form.setModel("mock", "model-b");
|
|
||||||
|
|
||||||
expect(form.getState().disclosure).toEqual({
|
|
||||||
showProjectField: true,
|
|
||||||
showModelField: true,
|
|
||||||
showThinkingField: true,
|
|
||||||
showModeField: true,
|
|
||||||
showIsolationField: true,
|
|
||||||
showArchiveOnFinishField: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides isolation unless the selected project can create a worktree", () => {
|
|
||||||
const nonGitTarget = target({
|
|
||||||
serverId: "host-a",
|
|
||||||
projectKey: "plain-project",
|
|
||||||
projectName: "Plain Project",
|
|
||||||
cwd: "/tmp/plain",
|
|
||||||
isGit: false,
|
|
||||||
});
|
|
||||||
const nonGit = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: [nonGitTarget], preferences: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
nonGit.setProject(nonGitTarget.optionId, { label: "Plain Project" });
|
|
||||||
|
|
||||||
expect(nonGit.getState().disclosure).toMatchObject({
|
|
||||||
showIsolationField: false,
|
|
||||||
showArchiveOnFinishField: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gitTarget = target({
|
|
||||||
serverId: "host-a",
|
|
||||||
projectKey: "git-project",
|
|
||||||
projectName: "Git Project",
|
|
||||||
cwd: "/tmp/git",
|
|
||||||
isGit: true,
|
|
||||||
});
|
|
||||||
const unsupportedHost = openWithHosts({
|
|
||||||
mode: "create",
|
|
||||||
hosts: [{ serverId: "host-a", label: "Host A" }],
|
|
||||||
defaults: { serverId: "host-a", projectTargets: [gitTarget], preferences: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
unsupportedHost.setProject(gitTarget.optionId, { label: "Git Project" });
|
|
||||||
|
|
||||||
expect(unsupportedHost.getState().disclosure).toMatchObject({
|
|
||||||
showIsolationField: false,
|
|
||||||
showArchiveOnFinishField: false,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitIsolation"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitArchiveOnFinish"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
(unsupportedHost.getState() as unknown as { submitIsolation?: string }).submitIsolation,
|
|
||||||
).toBeUndefined();
|
|
||||||
expect(
|
|
||||||
(unsupportedHost.getState() as unknown as { submitArchiveOnFinish?: boolean })
|
|
||||||
.submitArchiveOnFinish,
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows modes for providers without selectable models", () => {
|
|
||||||
const form = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
|
|
||||||
form.applyProviderSnapshot("host-a", providerSnapshot([]));
|
|
||||||
form.setModel("mock", "");
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
selectedProvider: "mock",
|
|
||||||
selectedModel: "",
|
|
||||||
disclosure: {
|
|
||||||
showModeField: true,
|
|
||||||
showThinkingField: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves stored worktree isolation until host resolution proves it unavailable", () => {
|
|
||||||
const nonGitTarget = target({
|
|
||||||
serverId: "host-b",
|
|
||||||
projectKey: "plain-project",
|
|
||||||
projectName: "Plain Project",
|
|
||||||
cwd: "/repo/b",
|
|
||||||
isGit: false,
|
|
||||||
});
|
|
||||||
const form = open({
|
|
||||||
mode: "edit",
|
|
||||||
schedule: scheduleOnHost({
|
|
||||||
serverId: "host-b",
|
|
||||||
serverName: "Host B",
|
|
||||||
cwd: "/repo/b",
|
|
||||||
model: "model-b",
|
|
||||||
}),
|
|
||||||
defaults: { serverId: null, projectTargets: [nonGitTarget], preferences: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
isolation: "worktree",
|
|
||||||
effectiveIsolation: "worktree",
|
|
||||||
canUseWorktreeIsolation: false,
|
|
||||||
providerResolutionByServerId: { "host-b": "pending" },
|
|
||||||
});
|
|
||||||
|
|
||||||
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
isolation: "worktree",
|
|
||||||
effectiveIsolation: "local",
|
|
||||||
canUseWorktreeIsolation: false,
|
|
||||||
providerResolutionByServerId: { "host-b": "complete" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes interval cadences to cron cadences when opening the form", () => {
|
|
||||||
const form = open({
|
|
||||||
mode: "edit",
|
|
||||||
schedule: scheduleOnHost({
|
|
||||||
serverId: "host-a",
|
|
||||||
serverName: "Host A",
|
|
||||||
cwd: "/repo/a",
|
|
||||||
model: "model-a",
|
|
||||||
cadence: { type: "every", everyMs: 60_000 },
|
|
||||||
}),
|
|
||||||
defaults: {
|
|
||||||
serverId: null,
|
|
||||||
projectTargets: PROJECT_TARGETS,
|
|
||||||
preferences: {},
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(form.getState().cadence).toEqual({
|
|
||||||
type: "cron",
|
|
||||||
expression: "* * * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves an edited schedule's interval cadence until the cadence changes", () => {
|
|
||||||
const originalCadence = { type: "every" as const, everyMs: 90 * 60_000 };
|
|
||||||
const form = open({
|
|
||||||
mode: "edit",
|
|
||||||
schedule: scheduleOnHost({
|
|
||||||
serverId: "host-a",
|
|
||||||
serverName: "Host A",
|
|
||||||
cwd: "/repo/a",
|
|
||||||
model: "model-a",
|
|
||||||
cadence: originalCadence,
|
|
||||||
}),
|
|
||||||
defaults: {
|
|
||||||
serverId: null,
|
|
||||||
projectTargets: PROJECT_TARGETS,
|
|
||||||
preferences: {},
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(form.getState().cadence).toEqual({
|
|
||||||
type: "cron",
|
|
||||||
expression: "*/59 * * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
|
|
||||||
form.setName("Renamed without touching cadence");
|
|
||||||
|
|
||||||
expect(form.getState().submitCadence).toEqual(originalCadence);
|
|
||||||
|
|
||||||
form.setCadence({
|
|
||||||
type: "cron",
|
|
||||||
expression: "0 9 * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(form.getState().submitCadence).toEqual({
|
|
||||||
type: "cron",
|
|
||||||
expression: "0 9 * * *",
|
|
||||||
timezone: "Europe/Madrid",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears provider selection while resolving a different project", () => {
|
|
||||||
const form = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
form.setPrompt("Run on a selected project.");
|
|
||||||
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
|
|
||||||
form.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS));
|
|
||||||
form.setModel("mock", "model-a");
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
selectedServerId: "host-a",
|
|
||||||
workingDir: "/repo/a",
|
|
||||||
selectedProvider: "mock",
|
|
||||||
selectedModel: "model-a",
|
|
||||||
canSubmit: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.setProject(buildProjectOptionId("host-b", "project-b"), { label: "Project B" });
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
selectedServerId: "host-b",
|
|
||||||
workingDir: "/repo/b",
|
|
||||||
selectedProvider: null,
|
|
||||||
selectedModel: "",
|
|
||||||
modelSelectorProviders: [],
|
|
||||||
canSubmit: false,
|
|
||||||
providerResolutionByServerId: { "host-a": "complete", "host-b": "pending" },
|
|
||||||
providerSnapshotRequest: { serverId: "host-b", cwd: "/repo/b" },
|
|
||||||
});
|
|
||||||
|
|
||||||
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
selectedServerId: "host-b",
|
|
||||||
selectedProvider: null,
|
|
||||||
selectedModel: "",
|
|
||||||
providerResolutionByServerId: { "host-a": "complete", "host-b": "complete" },
|
|
||||||
providerSnapshotRequest: null,
|
|
||||||
modelSelectorProviders: [expect.objectContaining({ id: "mock", label: "Mock" })],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies new project targets without resetting selected values", () => {
|
|
||||||
const projectA = PROJECT_TARGETS[0];
|
|
||||||
const form = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: [projectA], preferences: {} },
|
|
||||||
});
|
|
||||||
form.setName("Draft name");
|
|
||||||
form.setPrompt("Draft prompt");
|
|
||||||
form.setProject(projectA.optionId, { label: "Project A" });
|
|
||||||
|
|
||||||
form.applyProjectTargets([
|
|
||||||
projectA,
|
|
||||||
target({
|
|
||||||
serverId: "host-a",
|
|
||||||
projectKey: "project-c",
|
|
||||||
projectName: "Project C",
|
|
||||||
cwd: "/repo/c",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(form.getState()).toMatchObject({
|
|
||||||
name: "Draft name",
|
|
||||||
prompt: "Draft prompt",
|
|
||||||
selectedServerId: "host-a",
|
|
||||||
workingDir: "/repo/a",
|
|
||||||
projectDisplay: { label: "Project A" },
|
|
||||||
selectedProjectOptionId: projectA.optionId,
|
|
||||||
projectOptions: [
|
|
||||||
{
|
|
||||||
id: projectA.optionId,
|
|
||||||
value: projectA.optionId,
|
|
||||||
label: "Project A",
|
|
||||||
testID: "schedule-project-option-project-a",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: buildProjectOptionId("host-a", "project-c"),
|
|
||||||
value: buildProjectOptionId("host-a", "project-c"),
|
|
||||||
label: "Project C",
|
|
||||||
testID: "schedule-project-option-project-c",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
|
|
||||||
const savedPreferences: FormPreferences = {
|
|
||||||
provider: "mock",
|
|
||||||
providerPreferences: {
|
|
||||||
mock: {
|
|
||||||
model: "model-b",
|
|
||||||
mode: "load-test",
|
|
||||||
thinkingByModel: { "model-b": "high" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isolation: "worktree",
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
create.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
|
|
||||||
|
|
||||||
applyPreferences(create, savedPreferences);
|
|
||||||
|
|
||||||
expect(create.getState()).toMatchObject({
|
|
||||||
selectedProvider: "mock",
|
|
||||||
selectedModel: "model-b",
|
|
||||||
selectedMode: "load-test",
|
|
||||||
selectedThinkingOptionId: "high",
|
|
||||||
isolation: "worktree",
|
|
||||||
});
|
|
||||||
|
|
||||||
const userEdited = open({
|
|
||||||
mode: "create",
|
|
||||||
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
userEdited.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
|
|
||||||
userEdited.setModel("mock", "model-a");
|
|
||||||
userEdited.setIsolation("local");
|
|
||||||
|
|
||||||
applyPreferences(userEdited, savedPreferences);
|
|
||||||
|
|
||||||
expect(userEdited.getState()).toMatchObject({
|
|
||||||
selectedProvider: "mock",
|
|
||||||
selectedModel: "model-a",
|
|
||||||
isolation: "local",
|
|
||||||
});
|
|
||||||
|
|
||||||
const edit = open({
|
|
||||||
mode: "edit",
|
|
||||||
schedule: scheduleOnHost({
|
|
||||||
serverId: "host-a",
|
|
||||||
serverName: "Host A",
|
|
||||||
cwd: "/repo/a",
|
|
||||||
model: "model-a",
|
|
||||||
}),
|
|
||||||
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
|
|
||||||
});
|
|
||||||
edit.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
|
|
||||||
|
|
||||||
applyPreferences(edit, { ...savedPreferences, isolation: "local" });
|
|
||||||
|
|
||||||
expect(edit.getState()).toMatchObject({
|
|
||||||
selectedProvider: "mock",
|
|
||||||
selectedModel: "model-a",
|
|
||||||
selectedMode: "load-test",
|
|
||||||
isolation: "worktree",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ export interface ScheduleProjectTarget {
|
|||||||
projectKey: string;
|
projectKey: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
isGit: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProjectOptionId(serverId: string, projectKey: string): string {
|
export function buildProjectOptionId(serverId: string, projectKey: string): string {
|
||||||
@@ -39,7 +38,6 @@ export function buildScheduleProjectTargets(
|
|||||||
projectKey: project.projectKey,
|
projectKey: project.projectKey,
|
||||||
projectName: project.projectName,
|
projectName: project.projectName,
|
||||||
cwd,
|
cwd,
|
||||||
isGit: Boolean(host.gitRuntime),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets";
|
|
||||||
import { useScheduleFormModel } from "./use-schedule-form-model";
|
|
||||||
import type { ScheduleFormSnapshot } from "./schedule-form-model";
|
|
||||||
|
|
||||||
const HOSTS = [{ serverId: "host-a", label: "Host A" }] as const;
|
|
||||||
const PROJECT_A_ID = buildProjectOptionId("host-a", "project-a");
|
|
||||||
|
|
||||||
function projectTarget(input: {
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
}): ScheduleProjectTarget {
|
|
||||||
return {
|
|
||||||
optionId: buildProjectOptionId("host-a", input.projectKey),
|
|
||||||
serverId: "host-a",
|
|
||||||
serverName: "Host A",
|
|
||||||
projectKey: input.projectKey,
|
|
||||||
projectName: input.projectName,
|
|
||||||
cwd: input.cwd,
|
|
||||||
isGit: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSnapshot(projectTargets: readonly ScheduleProjectTarget[]): ScheduleFormSnapshot {
|
|
||||||
return {
|
|
||||||
mode: "create",
|
|
||||||
hosts: HOSTS,
|
|
||||||
defaults: {
|
|
||||||
serverId: "host-a",
|
|
||||||
projectTargets,
|
|
||||||
preferences: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useScheduleFormModel", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps one model instance and draft state while open snapshot inputs churn", () => {
|
|
||||||
const firstTargets = [
|
|
||||||
projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }),
|
|
||||||
];
|
|
||||||
const { result, rerender } = renderHook(({ snapshot }) => useScheduleFormModel(snapshot), {
|
|
||||||
initialProps: { snapshot: createSnapshot(firstTargets) },
|
|
||||||
});
|
|
||||||
const openedModel = result.current;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
openedModel.setName("Draft name");
|
|
||||||
openedModel.setPrompt("Run the draft");
|
|
||||||
openedModel.setProject(PROJECT_A_ID, { label: "Project A" });
|
|
||||||
});
|
|
||||||
|
|
||||||
rerender({
|
|
||||||
snapshot: createSnapshot([
|
|
||||||
projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current).toBe(openedModel);
|
|
||||||
expect(result.current.getState()).toMatchObject({
|
|
||||||
name: "Draft name",
|
|
||||||
prompt: "Run the draft",
|
|
||||||
selectedServerId: "host-a",
|
|
||||||
workingDir: "/repo/a",
|
|
||||||
projectDisplay: { label: "Project A" },
|
|
||||||
selectedProjectOptionId: PROJECT_A_ID,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model";
|
|
||||||
|
|
||||||
export function useScheduleFormModel(snapshot: ScheduleFormSnapshot) {
|
|
||||||
const [model] = useState(() => openScheduleForm(snapshot));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
model.close();
|
|
||||||
};
|
|
||||||
}, [model]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
model.applyHosts(snapshot.hosts);
|
|
||||||
model.applyProjectTargets(snapshot.defaults.projectTargets);
|
|
||||||
model.applyPreferences(snapshot.defaults.preferences);
|
|
||||||
}, [model, snapshot.hosts, snapshot.defaults.preferences, snapshot.defaults.projectTargets]);
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
|
||||||
import type { ScheduleFormModel, ScheduleFormState } from "./schedule-form-model";
|
|
||||||
|
|
||||||
export function useScheduleFormProviderSnapshot(
|
|
||||||
model: ScheduleFormModel,
|
|
||||||
state: ScheduleFormState,
|
|
||||||
) {
|
|
||||||
const serverId = state.providerSnapshotRequest?.serverId ?? state.selectedServerId;
|
|
||||||
const cwd = state.providerSnapshotRequest?.cwd ?? state.workingDir;
|
|
||||||
const enabled = state.targetKind === "new-agent" && Boolean(serverId && cwd.trim());
|
|
||||||
const snapshot = useProvidersSnapshot(serverId ?? null, {
|
|
||||||
cwd,
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || !serverId || !snapshot.entries) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
model.applyProviderSnapshot(serverId, { entries: snapshot.entries });
|
|
||||||
}, [enabled, model, serverId, snapshot.entries]);
|
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user