Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 219050a7ab feat: project settings page with paseo.json editor
Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
2026-04-26 11:37:47 +07:00
Mohamed Boudra 18903bcecb refactor: simplify startup splash screen to logo with shimmer
Replace text-heavy splash ("Welcome to Paseo", "Starting local
server...", "Connecting...") with just the logo centered with a
shimmer sweep effect. Error state preserved as-is.
2026-04-26 11:30:07 +07:00
Mohamed Boudra a2c68cc689 feat: restore last workspace on cold start
Persist the user's last active workspace and navigate directly to it
on cold start instead of always landing on the open-project screen.

- Unified bootstrap into one cross-platform wait step after
  platform-specific connection setup
- Store-level wait with workspace preference: preferred host online
  resolves immediately, grace period if a different host connects
  first, bounded 5s timeout for unreachable hosts
- index.tsx is fully declarative — reads resolved target from context,
  no useEffect/promises/refs/timers
2026-04-26 11:28:46 +07:00
Mohamed Boudra 843d9234bd refactor: tighten tool call badge hover affordances
Move the open-file button inline after the summary text so the click
target sits next to what it acts on, and replace the tool icon with the
chevron on hover instead of showing a separate chevron at the row's end.
Hovering the icon slot keeps the tool icon visible for predictability.
2026-04-26 11:11:42 +07:00
Mohamed Boudra 5b47b6594f refactor: replace settings buttons with switches and simplify provider sheet
Swap daemon management and keep-running-after-quit buttons for Switch
toggles. Remove the restart/start daemon control. Simplify the provider
diagnostic sheet to reuse shared settings styles and SettingsSection.
Drop the unused "sm" size variant from the Switch component. Remove
trailing periods from hint text for consistency.
2026-04-26 08:15:28 +07:00
Mohamed Boudra 521944f19b feat: add open-file button to tool call badges
Show a file-symlink icon on hover that opens the file referenced by read,
edit, write, and simple shell commands (cat, head, tail, etc). Extracts
the file path from tool call details via a new utility with tests.
2026-04-26 08:15:16 +07:00
Mohamed Boudra 1ec448d2b9 fix: prevent bootstrap phase regression when an existing host comes online first
When a saved host reconnects before bootstrapDesktop finishes, the phase
was regressed from "online" back to "connecting". Track the race settlement
and skip the setPhase("connecting") call if anyOnline already won. Also
extract getEarliestOnlineHostServerId into HostRuntimeStore and use it to
initialize the bootstrap phase, avoiding a flash of the starting screen.
2026-04-26 08:15:06 +07:00
Mohamed Boudra b7310b9e90 feat(desktop): simplify quit lifecycle with sync PID check and shutdown dialog
Replace the async daemon status resolution during quit with a synchronous
PID file check, avoiding races when the event loop is shutting down. Show
a blocking dialog so users know the daemon is stopping. Add --kill-timeout
to the CLI stop command for the desktop app to control SIGKILL wait time.
2026-04-26 08:14:55 +07:00
Mohamed Boudra f9fcd6e032 Merge branch 'feature/desktop-daemon-settings' 2026-04-25 14:08:46 +07:00
Mohamed Boudra cfc27f2576 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-25 14:07:13 +07:00
Mohamed Boudra 4dd3b0006b Accept string form for worktree setup/teardown
Lifecycle config now takes either a multiline shell script string or an
array of commands, making it friendlier to edit in a textarea. Schema
parsing moved to Zod so coercion stays in one place.
2026-04-25 14:06:56 +07:00
github-actions[bot] a8ffbc8003 fix: update lockfile signatures and Nix hash 2026-04-25 07:04:36 +00:00
Mohamed Boudra 4b3603604a feat: enable/disable providers from settings
Adds a per-provider toggle in Settings → Providers, persisted via daemon
config, so users can turn off providers they don't have installed (or
don't want to use) without removing them from the registry.

Disabled providers stay visible everywhere — settings, `paseo provider
ls`, MCP `list_providers`, model/mode lists — marked as disabled rather
than silently filtered. Availability probes are skipped for them, and
attempts to launch an agent or fetch models/modes from a disabled
provider return a clear "Provider '<id>' is disabled" error.

Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider
summary) are backward-compatible: optional with a default of true so
older clients continue to parse new daemon messages.

CLI: `paseo provider ls` gains an ENABLED column; JSON output includes
the new field.
2026-04-25 14:00:38 +07:00
Mohamed Boudra ae3e88e7e1 feat(desktop): split daemon and release settings to main process
Move daemon management and release channel settings out of the renderer's
AsyncStorage and into a desktop-owned settings store persisted by the main
process. Adds a "Keep daemon running after quit" option, a daemon lifecycle
section in settings, and a one-time migration of legacy renderer-owned
desktop settings.
2026-04-25 13:59:07 +07:00
Mohamed Boudra de42858213 fix: reopen worktrees under the right project 2026-04-24 21:43:23 +07:00
Mohamed Boudra db8934c53e fix: keep unavailable provider agents in history 2026-04-24 21:43:09 +07:00
Mohamed Boudra 4b7199f53b feat: improve provider settings and model selection 2026-04-24 21:42:47 +07:00
Mohamed Boudra d3f9655e33 fix: require providers for new CLI agents 2026-04-24 21:40:59 +07:00
Mohamed Boudra 2b37276577 Fix initial terminal resize for nvim 2026-04-24 15:57:13 +07:00
Mohamed Boudra 83689ef0c5 fix(app): restore assistant link color 2026-04-24 14:03:49 +07:00
Mohamed Boudra 9a4528129f fix: suppress terminal query response leaks 2026-04-24 13:58:53 +07:00
Mohamed Boudra 231ebdcfb1 fix: preserve zsh prompt in packaged desktop (#544) 2026-04-24 13:54:20 +07:00
136 changed files with 14525 additions and 1604 deletions
+244
View File
@@ -0,0 +1,244 @@
# DESIGN-SYSTEM.md
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
---
## 1. Character
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
---
## 2. Component reuse
A semantic element used in three or more places is a primitive. One of a kind is a screen.
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
Before adding a new component, read `components/ui/`. The primitive usually exists.
---
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Weight has three tiers, applied by role:
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the callout actions (`packages/app/src/components/callout-card.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<CalloutCard>` titles (`packages/app/src/components/callout-card.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
---
## 4. Buttons
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Actionable errors are `<CalloutCard variant="error" actions={[...]} />` when the user can act on them from the page. The daemon-version-mismatch callout is the canonical use (`packages/app/src/components/daemon-version-mismatch-callout-source.tsx`).
Imperative errors are `Alert.alert("Error", "Unable to ...")` for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<CalloutCard>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
---
## 14. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
| Page-level callout with action | `packages/app/src/components/callout-card.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx` |
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
+12
View File
@@ -62,6 +62,18 @@ Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
```json
{
"worktree": {
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"teardown": "npm run db:drop || true"
}
}
```
Every `scripts` entry with `"type": "service"` receives these environment variables:
| Variable | Value |
+45
View File
@@ -0,0 +1,45 @@
# Paseo Glossary
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2128`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:9`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:576`); (b) **git merge conflict** (no current UI string).
## Open question — per-host project entry (TBD)
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
## Rename before landing (in-progress `Checkout*` references)
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155` — `ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90` — `checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600` — `ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521` — `checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
- `packages/app/src/utils/projects.test.ts:107,108,111,259` — `checkoutCount`, `onlineCheckoutCount`, `checkouts`.
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-0Izp63gVeBO9Efo4Uf8/EB3TkQ9FXJ4Td2Fe5UHm9eE=";
npmDepsHash = "sha256-6Bf44iWdDwe2MeHyS1Dj0Xhl5TNYuf50CI5uQHsQ97A=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+396 -347
View File
@@ -126,6 +126,26 @@
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.90.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz",
"integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -8443,31 +8463,31 @@
}
},
"node_modules/@mariozechner/clipboard": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz",
"integrity": "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.3.tgz",
"integrity": "sha512-e7jASirzfm+ROiOGFh843+cFZTy3DfzP+jldCvh8RnEk0C3QihDTn7dd7Yh7KAJydwIJ18FJSZ2swHvCJhk18g==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@mariozechner/clipboard-darwin-arm64": "0.3.2",
"@mariozechner/clipboard-darwin-universal": "0.3.2",
"@mariozechner/clipboard-darwin-x64": "0.3.2",
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.2",
"@mariozechner/clipboard-linux-arm64-musl": "0.3.2",
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2",
"@mariozechner/clipboard-linux-x64-gnu": "0.3.2",
"@mariozechner/clipboard-linux-x64-musl": "0.3.2",
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.2",
"@mariozechner/clipboard-win32-x64-msvc": "0.3.2"
"@mariozechner/clipboard-darwin-arm64": "0.3.3",
"@mariozechner/clipboard-darwin-universal": "0.3.3",
"@mariozechner/clipboard-darwin-x64": "0.3.3",
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.3",
"@mariozechner/clipboard-linux-arm64-musl": "0.3.3",
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.3",
"@mariozechner/clipboard-linux-x64-gnu": "0.3.3",
"@mariozechner/clipboard-linux-x64-musl": "0.3.3",
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.3",
"@mariozechner/clipboard-win32-x64-msvc": "0.3.3"
}
},
"node_modules/@mariozechner/clipboard-darwin-arm64": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.2.tgz",
"integrity": "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.3.tgz",
"integrity": "sha512-+zhuZGXqVrdkbIRdnwiZNbTJ7V3elq/A+C5d5laJoyhJgWs41eO5NUMkBkj6f23F2L4PRXEhdn5/ktlPx+bG3Q==",
"cpu": [
"arm64"
],
@@ -8481,9 +8501,9 @@
}
},
"node_modules/@mariozechner/clipboard-darwin-universal": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.2.tgz",
"integrity": "sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.3.tgz",
"integrity": "sha512-x9aRfTyndVqpEQ44LNNCK/EXZd9y8rWkLQgNhmWpby9PXrjPhNxfjUc2Db4mt4nJjU/4zzO8F5v/XyzlUGSdhQ==",
"license": "MIT",
"optional": true,
"os": [
@@ -8494,9 +8514,9 @@
}
},
"node_modules/@mariozechner/clipboard-darwin-x64": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.2.tgz",
"integrity": "sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.3.tgz",
"integrity": "sha512-6ut/NawB0KiYPCwrirgNp6Br62LntL978q7G6d/Rs2pmPvQb53bP96eUMYl+Y3a7Qk13bGZ4w9rVPFxRE9m9ag==",
"cpu": [
"x64"
],
@@ -8510,9 +8530,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-arm64-gnu": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.2.tgz",
"integrity": "sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.3.tgz",
"integrity": "sha512-gf3dH4kBddU1AOyHVB53mjLUFfJAKlTmxTMw51jdeg7eE7IjfEBXVvM4bifMtBxbWkT0eA0FUZ1C0KQ6Z5l6pw==",
"cpu": [
"arm64"
],
@@ -8526,9 +8546,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.2.tgz",
"integrity": "sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.3.tgz",
"integrity": "sha512-o1paj2+zmAQ/LaPS85XJCxhNowNQpxYM2cGY6pWvB5Kqmz6hZjl6CzDg5tbf1hZkn/Em6jpOaE2UtMxKdELBDA==",
"cpu": [
"arm64"
],
@@ -8542,9 +8562,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.2.tgz",
"integrity": "sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.3.tgz",
"integrity": "sha512-dkEhE4ekePJwMbBq9HP1//CFMNmDzA/iV9AXqBfvL5CWmmDIRXqh4A3YZt3tWO/HdMerX+xNCEiR7WiOsIG+UA==",
"cpu": [
"riscv64"
],
@@ -8558,9 +8578,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.2.tgz",
"integrity": "sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.3.tgz",
"integrity": "sha512-lT2yANtTLlEtFBIH3uGoRa/CQas/eBoLNi3qr9axQFoRgF4RGPSJ66yHOSnMECBneTIb1Iqv3UxokTfX27CdoQ==",
"cpu": [
"x64"
],
@@ -8574,9 +8594,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.2.tgz",
"integrity": "sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.3.tgz",
"integrity": "sha512-saq/MCB0QHK/7ZZLjAZ0QkbY944dyjOsur8gneGCfMitt+GOiE1CU4OUipHC4b6x8UDY9bRLsR4aBaxu22OFPA==",
"cpu": [
"x64"
],
@@ -8590,9 +8610,9 @@
}
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.2.tgz",
"integrity": "sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.3.tgz",
"integrity": "sha512-cGuvSj0/2X2w983yEcKw+i+r1EBej6ZZIN+fXG3eY2G/HaIQpbXpLvMxKyZ9LKtbZx+Z6q/gELEoSBMLML6BaQ==",
"cpu": [
"arm64"
],
@@ -8606,9 +8626,9 @@
}
},
"node_modules/@mariozechner/clipboard-win32-x64-msvc": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.2.tgz",
"integrity": "sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.3.tgz",
"integrity": "sha512-5hvaEq/bgYovTIGx43O/S7loIHYV3ue90WcV1dz0wdMXroVKZKeU/yfwM0PALQA1OcrEHiGXGySFReXr72lGtA==",
"cpu": [
"x64"
],
@@ -8634,6 +8654,330 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/@mariozechner/pi-agent-core": {
"version": "0.70.2",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.70.2.tgz",
"integrity": "sha512-g1hIdKyDwmQOoBGO0R4OhpemKeMENeK0vE5FJtuQKqEcsdCAkVBgZAK6aZUARYZVxMA718JS6WPLFWoddzjD7g==",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-ai": "^0.70.2",
"typebox": "^1.1.24"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@mariozechner/pi-ai": {
"version": "0.70.2",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.70.2.tgz",
"integrity": "sha512-+30LRPjXsXF+oI96DvGWMbdPGeqoLJvadh6UPev7wx2DzhC9FEqXkQcoMZ0usbCm7E9pl8ua8a9s/pQ5ikaUbg==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@google/genai": "^1.40.0",
"@mistralai/mistralai": "^2.2.0",
"chalk": "^5.6.2",
"openai": "6.26.0",
"partial-json": "^0.1.7",
"proxy-agent": "^6.5.0",
"typebox": "^1.1.24",
"undici": "^7.19.1",
"zod-to-json-schema": "^3.24.6"
},
"bin": {
"pi-ai": "dist/cli.js"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@mariozechner/pi-ai/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@mariozechner/pi-ai/node_modules/openai": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz",
"integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@mariozechner/pi-coding-agent": {
"version": "0.70.2",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.70.2.tgz",
"integrity": "sha512-asfNqV89HKAmKvJ1wENBY/UQMIf77kLtkzBrvXnMQV4YbH7D/6KT+VeVzPG6zm5PAZP2UtdLY9B9Cge7IxH37w==",
"license": "MIT",
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-tui": "^0.70.2",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
"extract-zip": "^2.0.1",
"file-type": "^21.1.1",
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",
"strip-ansi": "^7.1.0",
"typebox": "^1.1.24",
"undici": "^7.19.1",
"uuid": "^14.0.0",
"yaml": "^2.8.2"
},
"bin": {
"pi": "dist/cli.js"
},
"engines": {
"node": ">=20.6.0"
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.3"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/hosted-git-info": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz",
"integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==",
"license": "ISC",
"dependencies": {
"lru-cache": "^11.1.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@mariozechner/pi-coding-agent/node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/@mariozechner/pi-tui": {
"version": "0.70.2",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.70.2.tgz",
"integrity": "sha512-PtKC0NepnrYcqMx6MXkWTrBzC9tI62KeC6w940oT46lCbfvgmfqXciR15+9BZpxxc1H4jd3CMrKsmOPVeUqZ0A==",
"license": "MIT",
"dependencies": {
"@types/mime-types": "^2.1.4",
"chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0",
"marked": "^15.0.12",
"mime-types": "^3.0.1"
},
"engines": {
"node": ">=20.0.0"
},
"optionalDependencies": {
"koffi": "^2.9.0"
}
},
"node_modules/@mariozechner/pi-tui/node_modules/@types/mime-types": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
"license": "MIT"
},
"node_modules/@mariozechner/pi-tui/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@mariozechner/pi-tui/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@mistralai/mistralai": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz",
"integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
@@ -36216,6 +36560,12 @@
"node": ">= 0.6"
}
},
"node_modules/typebox": {
"version": "1.1.33",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.33.tgz",
"integrity": "sha512-+/MWwlQ1q2GSVwoxi/+u5JsHkgLQKcCN2Nsjree9c+K7GJu40qbaHrFETmfV1i9Fs1TcOVfynW+jJvIWcXtvjw==",
"license": "MIT"
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -38357,9 +38707,9 @@
"@getpaseo/highlight": "0.1.63-beta.1",
"@getpaseo/relay": "0.1.63-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.67.68",
"@mariozechner/pi-ai": "^0.67.68",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -38401,26 +38751,6 @@
"vitest": "^3.2.4"
}
},
"packages/server/node_modules/@anthropic-ai/sdk": {
"version": "0.90.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz",
"integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
@@ -38430,147 +38760,6 @@
"node": ">=12"
}
},
"packages/server/node_modules/@mariozechner/pi-agent-core": {
"version": "0.67.68",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.67.68.tgz",
"integrity": "sha512-anwFuzeUL7Qjbyih4DWY7w1zrOTrBxaz1L6+duLUuuzpHOun0EiP4KWIGTXPT5oJA7ZaeRNTyXJ7PlWfGQG33g==",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-ai": "^0.67.68"
},
"engines": {
"node": ">=20.0.0"
}
},
"packages/server/node_modules/@mariozechner/pi-ai": {
"version": "0.67.68",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.67.68.tgz",
"integrity": "sha512-DWWQmcb3IV3mbGXmzYBScfKA6kA52n/stY029eiBikrIxVT7DGLG6n7KSvTA2R4qBSgi1iFL3nGHtwxmtIn6Lg==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@google/genai": "^1.40.0",
"@mistralai/mistralai": "^2.2.0",
"@sinclair/typebox": "^0.34.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"openai": "6.26.0",
"partial-json": "^0.1.7",
"proxy-agent": "^6.5.0",
"undici": "^7.19.1",
"zod-to-json-schema": "^3.24.6"
},
"bin": {
"pi-ai": "dist/cli.js"
},
"engines": {
"node": ">=20.0.0"
}
},
"packages/server/node_modules/@mariozechner/pi-ai/node_modules/openai": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz",
"integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"packages/server/node_modules/@mariozechner/pi-coding-agent": {
"version": "0.67.68",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.67.68.tgz",
"integrity": "sha512-Bai2yUBpgjftqGvg3GNV9pxcMAatqJwQYsqM+7j39+tm+IpoW7hbMBnzXZQvykAwXIrTXpZFF5yp5ajeDI5Atg==",
"license": "MIT",
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.67.68",
"@mariozechner/pi-ai": "^0.67.68",
"@mariozechner/pi-tui": "^0.67.68",
"@silvia-odwyer/photon-node": "^0.3.4",
"ajv": "^8.17.1",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
"extract-zip": "^2.0.1",
"file-type": "^21.1.1",
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",
"strip-ansi": "^7.1.0",
"undici": "^7.19.1",
"uuid": "^11.1.0",
"yaml": "^2.8.2"
},
"bin": {
"pi": "dist/cli.js"
},
"engines": {
"node": ">=20.6.0"
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.2"
}
},
"packages/server/node_modules/@mariozechner/pi-coding-agent/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"packages/server/node_modules/@mariozechner/pi-tui": {
"version": "0.67.68",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.67.68.tgz",
"integrity": "sha512-RhcMaGz88lNOm5+9yx+YCIfXZALLbMxB2cwsoHzyOzs+OZAItw8tz6xJZPAnX0RHY+ENQEGMMDY9TF6pxxnkbA==",
"license": "MIT",
"dependencies": {
"@types/mime-types": "^2.1.4",
"chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0",
"marked": "^15.0.12",
"mime-types": "^3.0.1"
},
"engines": {
"node": ">=20.0.0"
},
"optionalDependencies": {
"koffi": "^2.9.0"
}
},
"packages/server/node_modules/@mistralai/mistralai": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.0.tgz",
"integrity": "sha512-JQUGIXjFWnw/J9LpTSf/ZXwVW3Sh8FBAcfTo5QvAHqkl4CfSiIwnjRJhMoAFcP6ncCe84YPU1ncDGX+p3OXnfg==",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
}
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
@@ -38658,18 +38847,6 @@
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"packages/server/node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
"license": "MIT"
},
"packages/server/node_modules/@types/mime-types": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
"license": "MIT"
},
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -38699,23 +38876,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"packages/server/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"packages/server/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -38728,15 +38888,6 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"packages/server/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"packages/server/node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
@@ -38757,30 +38908,6 @@
"node": ">=18"
}
},
"packages/server/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"packages/server/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"packages/server/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@@ -38828,44 +38955,6 @@
"node": ">= 0.8"
}
},
"packages/server/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/server/node_modules/hosted-git-info": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz",
"integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==",
"license": "ISC",
"dependencies": {
"lru-cache": "^11.1.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"packages/server/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -38875,15 +38964,6 @@
"node": ">=18"
}
},
"packages/server/node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -38917,21 +38997,6 @@
"node": ">= 0.6"
}
},
"packages/server/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/server/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -38956,22 +39021,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/server/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+119
View File
@@ -0,0 +1,119 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
interface ProjectsSettingsProject {
name: string;
path: string;
}
interface ProjectsSettingsFixtures {
editableProject: ProjectsSettingsProject;
}
const initialPaseoConfig = {
worktree: {
setup: ["echo initial setup"],
teardown: "echo cleanup",
customWorktreeField: "preserved",
},
scripts: {
dev: {
command: "npm run dev",
type: "server",
port: 3000,
customScriptField: "preserved",
},
},
customTopLevelField: "preserved",
};
const test = base.extend<ProjectsSettingsFixtures>({
editableProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-", {
paseoConfig: initialPaseoConfig,
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
});
await client.close();
await repo.cleanup();
},
});
async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(/\/projects$/);
}
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `${projectName} project details` }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup" })).toBeVisible({
timeout: 30_000,
});
}
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page.getByRole("textbox", { name: "Worktree setup" }).fill(setupCommands.join("\n"));
}
async function saveProjectConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
await expect
.poll(
async () => {
const contents = await readProjectConfigFile(project);
return JSON.parse(contents) as unknown;
},
{
timeout: 30_000,
},
)
.toMatchObject({
worktree: {
setup: updatedSetup,
teardown: initialPaseoConfig.worktree.teardown,
customWorktreeField: initialPaseoConfig.worktree.customWorktreeField,
},
scripts: {
dev: {
command: initialPaseoConfig.scripts.dev.command,
type: initialPaseoConfig.scripts.dev.type,
port: initialPaseoConfig.scripts.dev.port,
customScriptField: initialPaseoConfig.scripts.dev.customScriptField,
},
},
customTopLevelField: initialPaseoConfig.customTopLevelField,
});
const savedConfig = await readProjectConfigFile(project);
expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`);
}
async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<string> {
return readFile(path.join(project.path, "paseo.json"), "utf8");
}
test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
});
@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { getTabTestIds } from "./helpers/launcher";
import {
installTerminalRenderProbe,
readTerminalRenderProbe,
@@ -10,6 +11,54 @@ import {
} from "./helpers/terminal-probes";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
interface TerminalLayoutMetrics {
visibleSurfaceCount: number;
surfaceHeight: number;
rowCount: number;
rows: number | null;
cols: number | null;
renderedRowsHeight: number;
tabIds: string[];
}
async function readTerminalLayoutMetrics(page: Page): Promise<TerminalLayoutMetrics> {
const tabIds = await getTabTestIds(page);
return page.evaluate((currentTabIds) => {
const visibleSurfaces = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]'),
).filter((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const surface = visibleSurfaces[0] ?? null;
const renderedRows = Array.from(document.querySelectorAll<HTMLElement>(".xterm-rows > div"));
const firstRow = renderedRows[0] ?? null;
const lastRow = renderedRows.at(-1) ?? null;
const surfaceRect = surface?.getBoundingClientRect() ?? null;
const firstRowRect = firstRow?.getBoundingClientRect() ?? null;
const lastRowRect = lastRow?.getBoundingClientRect() ?? null;
const term = (
window as Window & {
__paseoTerminal?: {
rows?: number;
cols?: number;
};
}
).__paseoTerminal;
return {
visibleSurfaceCount: visibleSurfaces.length,
surfaceHeight: surfaceRect?.height ?? 0,
rowCount: renderedRows.length,
rows: typeof term?.rows === "number" ? term.rows : null,
cols: typeof term?.cols === "number" ? term.cols : null,
renderedRowsHeight:
firstRowRect && lastRowRect ? Math.max(0, lastRowRect.bottom - firstRowRect.top) : 0,
tabIds: currentTabIds,
};
}, tabIds);
}
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
let lastBufferText = "";
let lastProbe = await readTerminalRenderProbe(page);
@@ -53,6 +102,8 @@ async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout:
}
test.describe("Terminal alternate-screen transitions", () => {
test.describe.configure({ timeout: 120_000 });
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
@@ -138,4 +189,62 @@ test.describe("Terminal alternate-screen transitions", () => {
).toEqual([]);
});
});
test("opening vim in a new terminal fills the terminal surface", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1280, height: 900 });
await installTerminalRenderProbe(page);
await withTerminalInApp(page, harness, { name: "vim-layout" }, async () => {
await harness.setupPrompt(page);
const terminalSurface = harness.terminalSurface(page).first();
await expect(terminalSurface).toBeVisible({ timeout: 15_000 });
await terminalSurface.click();
const beforeVim = await readTerminalLayoutMetrics(page);
await startTerminalFrameSampling(page, 2_000);
await terminalSurface.pressSequentially("vim -Nu NONE -n\n", { delay: 0 });
await page.waitForTimeout(1_500);
const probe = await readTerminalRenderProbe(page);
const samples: TerminalLayoutMetrics[] = [];
for (let index = 0; index < 12; index += 1) {
samples.push(await readTerminalLayoutMetrics(page));
await page.waitForTimeout(50);
}
await testInfo.attach("reused-terminal-layout-metrics", {
body: JSON.stringify(
{
beforeVim,
probe: summarizeTerminalRenderProbe(probe),
samples,
},
null,
2,
),
contentType: "application/json",
});
const firstSample = samples[0];
const finalSample = samples.at(-1);
expect(firstSample, "expected an initial layout sample").toBeTruthy();
expect(finalSample, "expected a final layout sample").toBeTruthy();
expect(
finalSample?.visibleSurfaceCount ?? 0,
"opening vim should leave exactly one visible terminal surface",
).toBe(1);
expect(
finalSample?.renderedRowsHeight ?? 0,
"vim should render rows that fill most of the terminal surface",
).toBeGreaterThan((finalSample?.surfaceHeight ?? 0) * 0.75);
expect(
finalSample?.rowCount ?? 0,
"vim should leave a substantial number of rendered rows",
).toBeGreaterThan(Math.max(10, Math.floor((beforeVim.rowCount || 0) * 0.75)));
});
});
});
+71 -84
View File
@@ -48,6 +48,7 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { initializeHostRuntime, type StartupNavigationTarget } from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -72,7 +73,10 @@ import {
} from "@/runtime/host-runtime";
import {
addBrowserActiveWorkspaceLocationListener,
getLastNavigationWorkspaceRouteSelection,
hydrateLastNavigationWorkspaceRouteSelection,
syncNavigationActiveWorkspace,
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
@@ -102,14 +106,21 @@ export interface HostRuntimeBootstrapState {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
startupNavigation: HostRuntimeStartupNavigation | null;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
startupNavigation: null,
});
export interface HostRuntimeStartupNavigation {
target: StartupNavigationTarget;
workspaceSelection: ActiveWorkspaceSelection | null;
}
function PushNotificationRouter() {
const router = useRouter();
const pathname = usePathname();
@@ -179,19 +190,18 @@ function PushNotificationRouter() {
});
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
@@ -267,104 +277,78 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>(() =>
getHostRuntimeStore().getEarliestOnlineHostServerId() !== null ? "online" : "starting-daemon",
);
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const [startupNavigation, setStartupNavigation] = useState<HostRuntimeStartupNavigation | null>(
null,
);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setStartupNavigation(null);
setRetryToken((current) => current + 1);
}, []);
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
let startupWorkspaceSelection: ActiveWorkspaceSelection | null = null;
const abortController = new AbortController();
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
setStartupNavigation(null);
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
})();
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
if (!cancelled) {
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
}
} else {
setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
const loadStartupWorkspaceSelection = async (): Promise<ActiveWorkspaceSelection | null> => {
await hydrateLastNavigationWorkspaceRouteSelection();
startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection();
return startupWorkspaceSelection;
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
void initializeHostRuntime({
shouldManageDesktop,
loadSettings: loadSettingsFromStorage,
loadStartupWorkspaceSelection,
store,
setPhase,
setError,
isCancelled: () => cancelled,
signal: abortController.signal,
})
.then((target) => {
if (cancelled) {
return undefined;
}
setStartupNavigation({
target,
workspaceSelection: startupWorkspaceSelection,
});
return undefined;
})
.catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(
bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError),
);
return;
}
setPhase("online");
setError(null);
setStartupNavigation({
target: null,
workspaceSelection: startupWorkspaceSelection,
});
});
return () => {
cancelled = true;
cancelAnyOnline?.();
abortController.abort();
};
}, [retryToken]);
@@ -373,8 +357,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
phase,
error,
retry,
startupNavigation,
}),
[error, phase, retry],
[error, phase, retry, startupNavigation],
);
return (
@@ -845,6 +830,8 @@ function RootStack() {
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
{/*
@@ -0,0 +1,247 @@
import { describe, expect, it, vi } from "vitest";
import { initializeHostRuntime } from "./host-runtime-bootstrap";
type BootstrapStore = Parameters<typeof initializeHostRuntime>[0]["store"];
function createSettings(input: { manageBuiltInDaemon: boolean }) {
return {
theme: "auto" as const,
sendBehavior: "interrupt" as const,
manageBuiltInDaemon: input.manageBuiltInDaemon,
releaseChannel: "stable" as const,
};
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function createStore(overrides: Partial<BootstrapStore> = {}): BootstrapStore {
return {
loadFromStorage: vi.fn(async () => {}),
bootstrap: vi.fn(async () => {}),
bootstrapDesktop: vi.fn(async () => ({
ok: true as const,
listenAddress: "127.0.0.1:6767",
serverId: "srv_test",
hostname: "test",
})),
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {}),
waitForAnyConnectionOnline: vi.fn(() => ({
promise: Promise.resolve({ serverId: "srv_test" }),
cancel: vi.fn(),
})),
...overrides,
};
}
describe("initializeHostRuntime", () => {
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
const store = createStore();
const setPhase = vi.fn();
const setError = vi.fn();
const target = await initializeHostRuntime({
shouldManageDesktop: true,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase,
setError,
isCancelled: () => false,
});
expect(store.bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false });
expect(store.bootstrapDesktop).not.toHaveBeenCalled();
expect(target).toEqual({ serverId: "srv_test" });
expect(setPhase).toHaveBeenLastCalledWith("online");
expect(setError).toHaveBeenLastCalledWith(null);
});
it("waits for any saved host after non-managed connection setup before setting online", async () => {
const events: string[] = [];
const wait = createDeferred<{ serverId: string } | null>();
const store = createStore({
bootstrap: vi.fn(async () => {
events.push("bootstrap");
}),
waitForAnyConnectionOnline: vi.fn(() => {
events.push("wait");
return {
promise: wait.promise,
cancel: vi.fn(),
};
}),
});
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
const initPromise = initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => {
events.push("read-workspace-selection");
return null;
},
store,
setPhase,
setError: vi.fn(),
isCancelled: () => false,
});
await vi.waitFor(() => {
expect(events).toEqual(["phase:connecting", "bootstrap", "read-workspace-selection", "wait"]);
});
expect(setPhase).not.toHaveBeenCalledWith("online");
wait.resolve({ serverId: "srv_saved" });
await expect(initPromise).resolves.toEqual({ serverId: "srv_saved" });
expect(setPhase).toHaveBeenLastCalledWith("online");
});
it("passes the startup workspace server id into the shared wait", async () => {
const store = createStore();
await initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => ({
serverId: "srv_workspace",
workspaceId: "workspace-1",
}),
store,
setPhase: vi.fn(),
setError: vi.fn(),
isCancelled: () => false,
});
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({
preferredServerId: "srv_workspace",
});
});
it("passes a null preference into the shared wait when no startup workspace is persisted", async () => {
const store = createStore();
await initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase: vi.fn(),
setError: vi.fn(),
isCancelled: () => false,
});
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
});
it("uses desktop daemon startup only as the managed connection producer before the shared wait", async () => {
const events: string[] = [];
const store = createStore({
bootstrapDesktop: vi.fn(async () => {
events.push("bootstrap-desktop");
return {
ok: true as const,
listenAddress: "127.0.0.1:6767",
serverId: "srv_desktop",
hostname: "desktop",
};
}),
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {
events.push("add-connection");
}),
waitForAnyConnectionOnline: vi.fn(() => {
events.push("wait");
return {
promise: Promise.resolve({ serverId: "srv_desktop" }),
cancel: vi.fn(),
};
}),
});
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
const target = await initializeHostRuntime({
shouldManageDesktop: true,
loadSettings: async () => createSettings({ manageBuiltInDaemon: true }),
loadStartupWorkspaceSelection: async () => {
events.push("read-workspace-selection");
return null;
},
store,
setPhase,
setError: vi.fn(),
isCancelled: () => false,
});
expect(target).toEqual({ serverId: "srv_desktop" });
expect(events).toEqual([
"phase:starting-daemon",
"bootstrap-desktop",
"phase:connecting",
"add-connection",
"read-workspace-selection",
"wait",
"phase:online",
]);
expect(store.bootstrap).not.toHaveBeenCalled();
});
it("returns null without waiting when there are no saved hosts", async () => {
const store = createStore({
waitForAnyConnectionOnline: vi.fn(() => ({
promise: Promise.resolve(null),
cancel: vi.fn(),
})),
});
const target = await initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase: vi.fn(),
setError: vi.fn(),
isCancelled: () => false,
});
expect(target).toBeNull();
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
});
it("cancels the shared wait while initialization is still pending", async () => {
const abortController = new AbortController();
const wait = createDeferred<{ serverId: string } | null>();
const cancel = vi.fn();
const store = createStore({
waitForAnyConnectionOnline: vi.fn(() => ({
promise: wait.promise,
cancel,
})),
});
const setPhase = vi.fn();
const initPromise = initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase,
setError: vi.fn(),
isCancelled: () => true,
signal: abortController.signal,
});
await vi.waitFor(() => {
expect(store.waitForAnyConnectionOnline).toHaveBeenCalled();
});
abortController.abort();
await expect(initPromise).resolves.toBeNull();
expect(cancel).toHaveBeenCalledTimes(1);
expect(setPhase).not.toHaveBeenCalledWith("online");
});
});
@@ -0,0 +1,168 @@
import type { HostRuntimeBootstrapResult } from "@/runtime/host-runtime";
import type { Settings } from "@/hooks/use-settings";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
getLastNavigationWorkspaceRouteSelection,
hydrateLastNavigationWorkspaceRouteSelection,
} from "@/stores/navigation-active-workspace-store";
type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error";
export type StartupNavigationTarget = { serverId: string } | null;
interface HostRuntimeBootstrapStore {
loadFromStorage: () => Promise<void>;
bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>;
bootstrapDesktop: () => Promise<HostRuntimeBootstrapResult>;
addConnectionFromListenAndWaitForOnline: (input: {
listenAddress: string;
serverId: string;
hostname: string | null;
}) => Promise<unknown>;
waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => {
promise: Promise<StartupNavigationTarget>;
cancel: () => void;
};
}
export async function initializeHostRuntime(args: {
shouldManageDesktop: boolean;
loadSettings: () => Promise<Settings>;
loadStartupWorkspaceSelection?: () => Promise<ActiveWorkspaceSelection | null>;
store: HostRuntimeBootstrapStore;
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
setError: (error: string | null) => void;
isCancelled: () => boolean;
signal?: AbortSignal;
}): Promise<StartupNavigationTarget> {
const {
shouldManageDesktop,
loadSettings,
loadStartupWorkspaceSelection = readStartupWorkspaceSelection,
store,
setPhase,
setError,
isCancelled,
signal,
} = args;
const settings = await loadSettings();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (!isDesktopManaged) {
setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
} else {
const setupResult = await setupDesktopManagedConnection({
store,
setPhase,
setError,
isCancelled,
});
if (setupResult.type === "error") {
return null;
}
}
const startupWorkspaceSelection = await loadStartupWorkspaceSelection();
const target = await waitForStartupNavigationTarget({
store,
preferredServerId: startupWorkspaceSelection?.serverId ?? null,
signal,
});
if (!isCancelled()) {
setPhase("online");
setError(null);
}
return target;
}
async function setupDesktopManagedConnection(input: {
store: HostRuntimeBootstrapStore;
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
setError: (error: string | null) => void;
isCancelled: () => boolean;
}): Promise<BootstrapOutcome> {
const { store, setPhase, setError, isCancelled } = input;
setPhase("starting-daemon");
setError(null);
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!isCancelled()) {
setPhase("error");
setError(bootstrapResult.error);
}
return { type: "error", error: bootstrapResult.error };
}
if (!isCancelled()) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!isCancelled()) {
setPhase("error");
setError(message);
}
return { type: "error", error: message };
}
}
function waitForStartupNavigationTarget(input: {
store: HostRuntimeBootstrapStore;
preferredServerId: string | null;
signal?: AbortSignal;
}): Promise<StartupNavigationTarget> {
const { store, preferredServerId, signal } = input;
if (signal?.aborted) {
return Promise.resolve(null);
}
const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId });
return new Promise<StartupNavigationTarget>((resolve) => {
let settled = false;
const settle = (target: StartupNavigationTarget): void => {
if (settled) {
return;
}
settled = true;
signal?.removeEventListener("abort", abort);
onlineWait.cancel();
resolve(target);
};
const abort = (): void => {
settle(null);
};
signal?.addEventListener("abort", abort, { once: true });
onlineWait.promise.then((target) => {
settle(target);
return undefined;
});
});
}
type BootstrapOutcome = { type: "online" } | { type: "error"; error: string };
async function readStartupWorkspaceSelection(): Promise<ActiveWorkspaceSelection | null> {
await hydrateLastNavigationWorkspaceRouteSelection();
return getLastNavigationWorkspaceRouteSelection();
}
+157
View File
@@ -0,0 +1,157 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
const { redirectMock, runtimeStoreAccessMock, workspaceStoreAccessMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
storeReady: true,
bootstrapState: {
phase: "online",
error: null,
retry: vi.fn(),
startupNavigation: null,
} as HostRuntimeBootstrapState,
};
return {
redirectMock: vi.fn(),
runtimeStoreAccessMock: vi.fn(() => {
throw new Error("index.tsx must not read the host runtime store during startup navigation");
}),
workspaceStoreAccessMock: vi.fn(() => {
throw new Error(
"index.tsx must not read the navigation workspace store during startup navigation",
);
}),
state: hoistedState,
};
});
vi.mock("expo-router", () => ({
Redirect: ({ href }: { href: string }) => {
redirectMock(href);
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
},
usePathname: () => state.pathname,
}));
vi.mock("@/app/_layout", () => ({
useHostRuntimeBootstrapState: () => state.bootstrapState,
useStoreReady: () => state.storeReady,
}));
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: runtimeStoreAccessMock,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
shouldUseDesktopDaemon: () => false,
}));
vi.mock("@/screens/startup-splash-screen", () => ({
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
getLastNavigationWorkspaceRouteSelection: workspaceStoreAccessMock,
useIsLastNavigationWorkspaceRouteSelectionLoaded: workspaceStoreAccessMock,
}));
describe("Index route startup navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.resetModules();
state.pathname = "/";
state.storeReady = true;
state.bootstrapState = {
phase: "online",
error: null,
retry: vi.fn(),
startupNavigation: null,
};
redirectMock.mockReset();
runtimeStoreAccessMock.mockClear();
workspaceStoreAccessMock.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
async function renderIndex() {
const { default: Index } = await import("./index");
await act(async () => {
root.render(<Index />);
});
}
it("shows the startup splash until bootstrap has resolved the startup navigation target", async () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the startup target matches its host", async () => {
state.bootstrapState.startupNavigation = {
target: { serverId: "server-1" },
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
});
it("navigates to the startup host root when it differs from the persisted workspace host", async () => {
state.bootstrapState.startupNavigation = {
target: { serverId: "server-2" },
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("navigates to the startup host root when no persisted workspace exists", async () => {
state.bootstrapState.startupNavigation = {
target: { serverId: "server-2" },
workspaceSelection: null,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("falls back to welcome when bootstrap resolves no startup target", async () => {
state.bootstrapState.startupNavigation = {
target: null,
workspaceSelection: null,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/welcome");
});
});
+43 -44
View File
@@ -1,59 +1,58 @@
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import React from "react";
import { Redirect, usePathname, type Href } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import {
type HostRuntimeBootstrapState,
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => null,
);
}
const isDesktop = shouldUseDesktopDaemon();
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
const redirectRoute = resolveStartupRedirectRoute({
bootstrapState,
pathname,
storeReady,
});
useEffect(() => {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
}
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
function resolveStartupRedirectRoute(input: {
bootstrapState: HostRuntimeBootstrapState;
pathname: string;
storeReady: boolean;
}): Href | null {
const { bootstrapState, pathname, storeReady } = input;
if (!storeReady || !bootstrapState.startupNavigation) {
return null;
}
if (pathname !== "/" && pathname !== "") {
return null;
}
const { target, workspaceSelection } = bootstrapState.startupNavigation;
if (!target) {
return WELCOME_ROUTE;
}
if (workspaceSelection && target.serverId === workspaceSelection.serverId) {
return buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId);
}
return buildHostRootRoute(target.serverId);
}
@@ -0,0 +1,12 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -0,0 +1,7 @@
import SettingsScreen from "@/screens/settings-screen";
const PROJECTS_VIEW = { kind: "projects" as const };
export default function SettingsProjectsIndexRoute() {
return <SettingsScreen view={PROJECTS_VIEW} />;
}
@@ -271,6 +271,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
],
);
const handleToolCallOpenFile = useCallback(
(filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath });
},
[handleInlinePathPress],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
tail: streamItems,
@@ -485,6 +492,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
}
@@ -499,10 +507,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
+118 -10
View File
@@ -4,6 +4,7 @@ import {
Image,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type LayoutChangeEvent,
StyleProp,
ViewStyle,
@@ -45,6 +46,7 @@ import {
TriangleAlertIcon,
Scissors,
MicVocal,
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -64,6 +66,7 @@ import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
import {
hasMeaningfulToolCallDetail,
isPendingToolCallDetail,
@@ -994,7 +997,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
opacity: 0.72,
},
secondaryLabel: {
flex: 1,
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
@@ -1016,6 +1020,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[1],
flexShrink: 0,
},
openFileButton: {
marginLeft: theme.spacing[1],
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
@@ -1244,15 +1254,20 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
children: ReactNode;
}
function MarkdownInheritedText({
inheritedStyles,
textStyle,
style: overrideStyle,
children,
}: MarkdownInheritedTextProps) {
const style = useMemo(() => [inheritedStyles, textStyle], [inheritedStyles, textStyle]);
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
return <Text style={style}>{children}</Text>;
}
@@ -2026,6 +2041,7 @@ interface ExpandableBadgeProps {
isExpanded: boolean;
style?: StyleProp<ViewStyle>;
onToggle?: () => void;
onOpenFile?: () => void;
onDetailHoverChange?: (hovered: boolean) => void;
renderDetails?: () => ReactNode;
isLoading?: boolean;
@@ -2049,7 +2065,7 @@ function ExpandableBadgeSecondaryLabel({
onSecondaryLayout,
}: ExpandableBadgeSecondaryLabelProps) {
if (!secondaryLabel) {
return <View style={expandableBadgeStylesheet.spacer} />;
return null;
}
return (
<Text
@@ -2110,6 +2126,11 @@ interface ExpandableBadgeLabelRowProps {
onLabelRowLayout: (event: LayoutChangeEvent) => void;
onLabelLayout: (event: LayoutChangeEvent) => void;
onSecondaryLayout: (event: LayoutChangeEvent) => void;
showOpenFileButton: boolean;
isOpenFileHovered: boolean;
onOpenFilePress: (event: GestureResponderEvent) => void;
onOpenFileHoverIn: () => void;
onOpenFileHoverOut: () => void;
}
function ExpandableBadgeLabelRow({
@@ -2131,7 +2152,13 @@ function ExpandableBadgeLabelRow({
onLabelRowLayout,
onLabelLayout,
onSecondaryLayout,
showOpenFileButton,
isOpenFileHovered,
onOpenFilePress,
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
const { theme } = useUnistyles();
return (
<View
style={expandableBadgeStylesheet.labelRow}
@@ -2150,6 +2177,23 @@ function ExpandableBadgeLabelRow({
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
onSecondaryLayout={onSecondaryLayout}
/>
{showOpenFileButton ? (
<Pressable
onPress={onOpenFilePress}
onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel="Open file"
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<FileSymlink
size={14}
color={isOpenFileHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
) : null}
{isWebShimmer ? (
<ExpandableBadgeWebShimmerOverlay
label={label}
@@ -2206,6 +2250,23 @@ function renderExpandableBadgeIcon({
return null;
}
function renderExpandableBadgeIconSlot({
showChevron,
chevronColor,
chevronStyle,
iconNode,
}: {
showChevron: boolean;
chevronColor: string;
chevronStyle: StyleProp<ViewStyle>;
iconNode: ReactNode;
}): ReactNode {
if (showChevron) {
return <ChevronRight size={14} color={chevronColor} style={chevronStyle} />;
}
return iconNode;
}
function computeShimmerMetrics(input: {
label: string;
secondaryLabel: string | undefined;
@@ -2315,6 +2376,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
icon,
isExpanded,
onToggle,
onOpenFile,
onDetailHoverChange,
renderDetails,
isLoading = false,
@@ -2326,6 +2388,8 @@ const ExpandableBadge = memo(function ExpandableBadge({
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
const [isIconHovered, setIsIconHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails);
@@ -2345,6 +2409,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
() => onDetailHoverChange?.(false),
[onDetailHoverChange],
);
const handleOpenFilePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation?.();
onOpenFile?.();
},
[onOpenFile],
);
const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []);
const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []);
const handleIconHoverIn = useCallback(() => setIsIconHovered(true), []);
const handleIconHoverOut = useCallback(() => setIsIconHovered(false), []);
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
@@ -2541,12 +2616,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
const iconColor = resolveExpandableBadgeIconColor({ isError, isActive, theme });
const iconNode = renderExpandableBadgeIcon({ isError, iconColor, icon });
const iconSlotNode = renderExpandableBadgeIconSlot({
showChevron: isInteractive && isHovered && !isIconHovered,
chevronColor: theme.colors.foreground,
chevronStyle,
iconNode,
});
const pressHandlers = isInteractive
? {
onPress: onToggle,
onHoverIn: handleHoverIn,
onHoverOut: handleHoverOut,
onPressIn: handlePressIn,
onPressOut: handlePressOut,
accessibilityRole: "button" as const,
@@ -2554,7 +2633,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
: {};
return (
<View style={containerStyle} testID={testID}>
<View
style={containerStyle}
testID={testID}
onPointerEnter={isWeb ? handleHoverIn : undefined}
onPointerLeave={isWeb ? handleHoverOut : undefined}
>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
@@ -2562,7 +2646,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
style={pressableStyle}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
<View
style={expandableBadgeStylesheet.iconBadge}
onPointerEnter={isWeb ? handleIconHoverIn : undefined}
onPointerLeave={isWeb ? handleIconHoverOut : undefined}
>
{iconSlotNode}
</View>
<ExpandableBadgeLabelRow
label={label}
labelStyle={labelStyle}
@@ -2582,10 +2672,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
onLabelRowLayout={handleLabelRowLayout}
onLabelLayout={handleLabelLayout}
onSecondaryLayout={handleSecondaryLayout}
showOpenFileButton={Boolean(onOpenFile && isHovered)}
isOpenFileHovered={isOpenFileHovered}
onOpenFilePress={handleOpenFilePress}
onOpenFileHoverIn={handleOpenFileHoverIn}
onOpenFileHoverOut={handleOpenFileHoverOut}
/>
{isInteractive && isHovered ? (
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
) : null}
</View>
</Pressable>
{detailContent ? (
@@ -2614,6 +2706,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.testID !== next.testID) return false;
if (previous.onToggle !== next.onToggle) return false;
if (previous.onOpenFile !== next.onOpenFile) return false;
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
if (previous.renderDetails !== next.renderDetails) return false;
return true;
@@ -2632,6 +2725,7 @@ interface ToolCallProps {
disableOuterSpacing?: boolean;
onInlineDetailsHoverChange?: (hovered: boolean) => void;
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
onOpenFilePath?: (filePath: string) => void;
}
export const ToolCall = memo(function ToolCall({
@@ -2647,6 +2741,7 @@ export const ToolCall = memo(function ToolCall({
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -2705,6 +2800,17 @@ export const ToolCall = memo(function ToolCall({
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const extractedFilePath = useMemo(
() => extractToolCallFilePath(effectiveDetail),
[effectiveDetail],
);
const handleOpenFile = useMemo(() => {
if (!extractedFilePath || !onOpenFilePath) {
return undefined;
}
return () => onOpenFilePath(extractedFilePath);
}, [extractedFilePath, onOpenFilePath]);
const handleToggle = useCallback(() => {
if (isMobile) {
openToolCall({
@@ -2787,6 +2893,7 @@ export const ToolCall = memo(function ToolCall({
icon={IconComponent}
isExpanded={!isMobile && isExpanded}
onToggle={canOpenDetails ? handleToggle : undefined}
onOpenFile={handleOpenFile}
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
isLoading={status === "running" || status === "executing"}
isError={status === "failed"}
@@ -2808,5 +2915,6 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.metadata !== next.metadata) return false;
if (previous.isLastInSequence !== next.isLastInSequence) return false;
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
return true;
}
@@ -1,4 +1,4 @@
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
@@ -10,14 +10,19 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { formatTimeAgo } from "@/utils/time";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { ProviderProfileModel } from "@server/server/agent/provider-launch-config";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -26,23 +31,173 @@ interface ProviderDiagnosticSheetProps {
serverId: string;
}
function ModelRow({ model, isFirst }: { model: AgentModelDefinition; isFirst: boolean }) {
const rowStyle = useMemo(
() => [sheetStyles.modelRow, !isFirst && sheetStyles.modelRowBorder],
[isFirst],
);
function ModelRow({ model }: { model: AgentModelDefinition }) {
return (
<View style={rowStyle}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
<View style={MODEL_ROW_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
</View>
);
}
function CustomModelRow(props: {
model: ProviderProfileModel;
deleting: boolean;
onDelete: (modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { model, deleting, onDelete } = props;
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
const deleteButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
deleting ? sheetStyles.disabled : null,
],
[deleting],
);
return (
<View style={MODEL_ROW_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
<Pressable
onPress={handleDelete}
disabled={deleting}
hitSlop={8}
style={deleteButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Remove ${model.id}`}
>
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
</Pressable>
</View>
);
}
function CustomModelsSection(props: {
provider: string;
serverId: string;
refresh: (providers?: AgentProvider[]) => Promise<void>;
}) {
const { provider, serverId, refresh } = props;
const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
const providerConfig = config?.providers?.[provider];
const additionalModels = useMemo(
() => providerConfig?.additionalModels ?? [],
[providerConfig?.additionalModels],
);
const trimmedInput = input.trim();
const canAdd =
trimmedInput.length > 0 && !additionalModels.some((model) => model.id === trimmedInput);
const patchAdditionalModels = useCallback(
async (nextModels: ProviderProfileModel[]) => {
await patchConfig({
providers: {
[provider]: {
additionalModels: nextModels,
},
},
});
await refresh([provider as AgentProvider]);
},
[patchConfig, provider, refresh],
);
const handleAdd = useCallback(() => {
if (!canAdd) {
return;
}
setError(null);
setSaving(true);
void patchAdditionalModels([
...additionalModels,
{
id: trimmedInput,
label: trimmedInput,
},
])
.then(() => setInput(""))
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model");
})
.finally(() => setSaving(false));
}, [additionalModels, canAdd, patchAdditionalModels, trimmedInput]);
const handleDelete = useCallback(
(modelId: string) => {
setError(null);
setDeletingModelId(modelId);
void patchAdditionalModels(additionalModels.filter((model) => model.id !== modelId))
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to delete model");
})
.finally(() => {
setDeletingModelId((current) => (current === modelId ? null : current));
});
},
[additionalModels, patchAdditionalModels],
);
return (
<SettingsSection title="Custom models">
<View style={settingsStyles.card}>
<View style={INLINE_ROW_STYLE}>
<AdaptiveTextInput
value={input}
onChangeText={setInput}
onSubmitEditing={handleAdd}
placeholder="Model ID"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
// @ts-expect-error - outlineStyle is web-only
style={DIAGNOSTIC_INLINE_INPUT_STYLE}
/>
<Button
variant="default"
size="sm"
onPress={handleAdd}
disabled={!canAdd || saving}
accessibilityLabel="Add model"
>
{saving ? "Adding…" : "Add"}
</Button>
</View>
{additionalModels.map((model) => (
<CustomModelRow
key={model.id}
model={model}
deleting={deletingModelId === model.id}
onDelete={handleDelete}
/>
))}
</View>
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
</SettingsSection>
);
}
function DiagnosticCodeBlock(props: {
loading: boolean;
diagnostic: string | null;
@@ -73,7 +228,7 @@ function DiagnosticCodeBlock(props: {
}
return (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
<Text style={sheetStyles.mutedText}>No diagnostic available</Text>
</View>
);
}
@@ -110,7 +265,6 @@ export function ProviderDiagnosticSheet({
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
// clockTick is referenced so the label recomputes each timer tick.
void clockTick;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
}, [providerEntry?.fetchedAt, clockTick]);
@@ -197,10 +351,27 @@ export function ProviderDiagnosticSheet({
}
}, [visible, fetchDiagnostic]);
const modelsTrailing = useMemo(() => {
if (models.length === 0 && !fetchedAtLabel) return undefined;
return (
<View style={sheetStyles.modelsTrailing}>
{models.length > 0 ? (
<Text style={settingsStyles.sectionHeaderTitle}>{models.length}</Text>
) : null}
{models.length > 0 && fetchedAtLabel ? (
<Text style={settingsStyles.sectionHeaderTitle}>·</Text>
) : null}
{fetchedAtLabel ? (
<Text style={settingsStyles.sectionHeaderTitle}>Updated {fetchedAtLabel}</Text>
) : null}
</View>
);
}, [models.length, fetchedAtLabel]);
function renderModelsBody() {
if (models.length === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyState}>
<View style={sheetStyles.emptyRow}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models…</Text>
</View>
@@ -208,7 +379,7 @@ export function ProviderDiagnosticSheet({
}
if (models.length === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyState}>
<View style={sheetStyles.emptyRow}>
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
</View>
@@ -216,21 +387,21 @@ export function ProviderDiagnosticSheet({
}
if (models.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected.</Text>
<View style={sheetStyles.emptyRow}>
<Text style={sheetStyles.mutedText}>No models detected</Text>
</View>
);
}
if (filteredModels.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<View style={sheetStyles.emptyRow}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>No models match your search</Text>
</View>
);
}
return filteredModels.map((model: AgentModelDefinition, index) => (
<ModelRow key={model.id} model={model} isFirst={index === 0} />
return filteredModels.map((model: AgentModelDefinition) => (
<ModelRow key={model.id} model={model} />
));
}
@@ -243,33 +414,26 @@ export function ProviderDiagnosticSheet({
scrollable={false}
headerActions={headerActions}
>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
<SettingsSection title="Diagnostic">
<View style={settingsStyles.card}>
<DiagnosticCodeBlock
loading={loading}
diagnostic={diagnostic}
foregroundMutedColor={theme.colors.foregroundMuted}
/>
</View>
</View>
</SettingsSection>
<CustomModelsSection provider={provider} serverId={serverId} refresh={refresh} />
<View style={sheetStyles.modelsSection}>
<View style={sheetStyles.modelsHeader}>
<Text style={sheetStyles.sectionTitle}>Models</Text>
<View style={sheetStyles.modelsHeaderMeta}>
<Text style={sheetStyles.countText}>{models.length}</Text>
{fetchedAtLabel ? (
<>
<Text style={sheetStyles.metaDot}>·</Text>
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
</>
) : null}
</View>
<Text style={settingsStyles.sectionHeaderTitle}>Models</Text>
{modelsTrailing}
</View>
{models.length > 0 ? (
<View style={sheetStyles.searchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<View style={MODELS_CARD_STYLE}>
<View style={INLINE_ROW_STYLE}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
value={query}
onChangeText={setQuery}
@@ -281,33 +445,37 @@ export function ProviderDiagnosticSheet({
style={DIAGNOSTIC_SEARCH_INPUT_STYLE}
/>
</View>
) : null}
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
</View>
</View>
</AdaptiveModalSheet>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
section: {
gap: theme.spacing[2],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
mutedText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
monoHint: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginTop: theme.spacing[1],
},
errorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
marginTop: theme.spacing[2],
marginLeft: theme.spacing[1],
},
iconButton: {
width: 30,
height: 30,
@@ -321,20 +489,24 @@ const sheetStyles = StyleSheet.create((theme) => ({
disabled: {
opacity: 0.5,
},
codeBlock: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
overflow: "hidden",
maxHeight: 180,
inlineRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
inlineInput: {
flex: 1,
minWidth: 0,
paddingVertical: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
codeScroll: {
maxHeight: 180,
maxHeight: 200,
},
codeContent: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
codeText: {
fontFamily: Fonts.mono,
@@ -344,7 +516,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -352,70 +524,46 @@ const sheetStyles = StyleSheet.create((theme) => ({
modelsSection: {
flex: 1,
minHeight: 0,
gap: theme.spacing[2],
},
modelsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
modelsHeaderMeta: {
flexCard: {
flex: 1,
minHeight: 0,
},
modelsTrailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
metaDot: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
countText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
modelsScroll: {
flex: 1,
minHeight: 0,
},
modelsScrollContent: {
paddingBottom: theme.spacing[2],
paddingBottom: 0,
},
modelRow: {
paddingVertical: theme.spacing[3],
},
modelRowBorder: {
emptyRow: {
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
modelLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
modelId: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontFamily: Fonts.mono,
marginTop: 2,
},
emptyState: {
paddingVertical: theme.spacing[6],
alignItems: "center",
gap: theme.spacing[2],
},
}));
const DIAGNOSTIC_SHEET_SNAP_POINTS = ["50%", "85%"];
const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.searchInput, isWeb && { outlineStyle: "none" }];
const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.inlineInput, isWeb && { outlineStyle: "none" }];
const DIAGNOSTIC_INLINE_INPUT_STYLE = [sheetStyles.inlineInput, isWeb && { outlineStyle: "none" }];
const MODEL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const INLINE_ROW_STYLE = [settingsStyles.row, sheetStyles.inlineRow];
const MODELS_CARD_STYLE = [settingsStyles.card, sheetStyles.flexCard];
@@ -39,6 +39,7 @@ import {
FolderGit2,
GitPullRequest,
Globe,
Settings,
SquareTerminal,
Monitor,
MoreVertical,
@@ -53,6 +54,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
@@ -558,6 +560,10 @@ function ProjectKebabMenu({
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const settingsLeadingIcon = useMemo(
() => <Settings size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<MoreVertical
@@ -567,6 +573,11 @@ function ProjectKebabMenu({
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -579,6 +590,15 @@ function ProjectKebabMenu({
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-settings-${projectKey}`}
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
Open project settings
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={removeProjectLeadingIcon}
+21 -16
View File
@@ -164,7 +164,10 @@ export function TerminalPane({
const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
// Keep the latest measured size for whichever client currently owns the pane,
// but only dedupe resizes that this specific client has already pushed.
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
@@ -210,7 +213,7 @@ export function TerminalPane({
useEffect(() => {
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
lastReportedSizeRef.current = null;
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
@@ -296,7 +299,8 @@ export function TerminalPane({
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
useEffect(() => {
lastReportedSizeRef.current = null;
measuredTerminalSizeRef.current = null;
lastSentTerminalSizeRef.current = null;
}, [scopeKey]);
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
@@ -316,7 +320,7 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastReportedSizeRef.current,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
@@ -532,24 +536,25 @@ export function TerminalPane({
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
const { rows, cols } = input;
if (
!client ||
!terminalId ||
!isPaneFocused ||
!isWorkspaceFocused ||
!isAppVisible ||
rows <= 0 ||
cols <= 0
) {
if (rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const previous = lastReportedSizeRef.current;
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) {
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
const previousSent = lastSentTerminalSizeRef.current;
if (
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
) {
return;
}
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
+32 -12
View File
@@ -6,7 +6,7 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
@@ -104,6 +104,7 @@ export function Button({
style,
textStyle,
disabled,
loading = false,
accessibilityRole,
...props
}: PropsWithChildren<
@@ -113,10 +114,12 @@ export function Button({
leftIcon?: LeftIcon;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
loading?: boolean;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const isDisabled = disabled || loading;
let variantStyle: ViewStyle;
if (variant === "default") {
@@ -150,10 +153,10 @@ export function Button({
sizeStyle,
variantStyle,
pressed ? styles.pressed : null,
disabled ? styles.disabled : null,
isDisabled ? styles.disabled : null,
style,
],
[sizeStyle, variantStyle, disabled, style],
[sizeStyle, variantStyle, isDisabled, style],
);
const resolvedTextStyle = useMemo(
@@ -168,7 +171,30 @@ export function Button({
[variant, textStyle, isGhostHovered],
);
const accessibilityState = useMemo(
() => ({ disabled: isDisabled, busy: loading }),
[isDisabled, loading],
);
function resolveIconColor(): string {
if (variant === "default") {
return theme.colors.accentForeground;
}
if (variant === "ghost") {
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
}
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
@@ -176,14 +202,7 @@ export function Button({
return <View>{leftIcon}</View>;
}
let color: string;
if (variant === "default") {
color = theme.colors.accentForeground;
} else if (variant === "ghost") {
color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
} else {
color = theme.colors.foreground;
}
const color = resolveIconColor();
const iconSize = ICON_SIZE[size];
// Render function
@@ -208,7 +227,8 @@ export function Button({
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
accessibilityState={accessibilityState}
disabled={isDisabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
@@ -0,0 +1,163 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Switch } from "./switch";
const { theme } = vi.hoisted(() => ({
theme: {
opacity: { 50: 0.5 },
colors: {
surface3: "#333",
accent: "#0a84ff",
palette: { white: "#fff" },
},
},
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Easing: {
ease: "ease",
inOut: (value: unknown) => value,
},
interpolateColor: (value: number, _input: number[], output: string[]) =>
value >= 1 ? output[1] : output[0],
useAnimatedStyle: (factory: () => unknown) => factory(),
useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
withTiming: (value: unknown) => value,
}));
vi.mock("react-native", () => ({
Pressable: ({
accessibilityLabel,
accessibilityRole,
accessibilityState,
children,
disabled,
onPress,
testID,
}: {
accessibilityLabel?: string;
accessibilityRole?: string;
accessibilityState?: { checked?: boolean; disabled?: boolean };
children: React.ReactNode;
disabled?: boolean;
onPress?: (event: { stopPropagation: () => void }) => void;
testID?: string;
}) =>
React.createElement(
"button",
{
"aria-checked": accessibilityState?.checked,
"aria-disabled": accessibilityState?.disabled,
"aria-label": accessibilityLabel,
"data-disabled": disabled,
"data-testid": testID,
onClick: () => onPress?.({ stopPropagation: vi.fn() }),
role: accessibilityRole,
type: "button",
},
children,
),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
describe("Switch", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function renderSwitch(props: React.ComponentProps<typeof Switch>): HTMLElement {
act(() => {
root?.render(<Switch {...props} />);
});
const switchElement = container?.querySelector('[role="switch"]') as HTMLElement | null;
if (!switchElement) {
throw new Error("Expected switch element to render");
}
return switchElement;
}
function pressSwitch(switchElement: HTMLElement): void {
act(() => {
switchElement.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
it("renders with accessibility role switch", () => {
const switchElement = renderSwitch({
value: false,
accessibilityLabel: "Enable Claude",
testID: "provider-switch",
});
expect(switchElement.getAttribute("role")).toBe("switch");
expect(switchElement.getAttribute("aria-label")).toBe("Enable Claude");
});
it("calls onValueChange with the toggled value when pressed", () => {
const onValueChange = vi.fn();
const switchElement = renderSwitch({ value: false, onValueChange });
pressSwitch(switchElement);
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange).toHaveBeenCalledWith(true);
});
it("does not call onValueChange when disabled", () => {
const onValueChange = vi.fn();
const switchElement = renderSwitch({ value: false, onValueChange, disabled: true });
pressSwitch(switchElement);
expect(onValueChange).not.toHaveBeenCalled();
});
it("reflects checked accessibility state from value", () => {
const switchElement = renderSwitch({ value: true });
expect(switchElement.getAttribute("aria-checked")).toBe("true");
act(() => {
root?.render(<Switch value={false} />);
});
expect(switchElement.getAttribute("aria-checked")).toBe("false");
});
});
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useMemo } from "react";
import {
Pressable,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import Animated, {
Easing,
interpolateColor,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface SwitchProps {
value: boolean;
onValueChange?: (value: boolean) => void;
disabled?: boolean;
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
}
const TRACK = { width: 34, height: 20 };
const THUMB = 16;
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
export function Switch({
value,
onValueChange,
disabled = false,
accessibilityLabel,
testID,
style,
}: 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(() => ({
transform: [{ translateX: progress.value * thumbTravel }],
}));
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
if (disabled) return;
onValueChange?.(!value);
},
[disabled, onValueChange, value],
);
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
const pressableStyle = useMemo(
() => [disabled ? styles.disabled : null, 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 (
<Pressable
onPress={handlePress}
disabled={disabled}
hitSlop={8}
accessibilityRole="switch"
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={pressableStyle}
>
<Animated.View style={trackStyle}>
<Animated.View style={thumbStyle} />
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
track: {
justifyContent: "center",
},
thumb: {
backgroundColor: theme.colors.palette.white,
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,
shadowOpacity: 1,
elevation: 2,
},
disabled: {
opacity: theme.opacity[50],
},
}));
@@ -0,0 +1,300 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocalDaemonSection } from "./desktop-updates-section";
const { alertMock, confirmDialogMock, settingsState, daemonStatusState } = vi.hoisted(() => ({
alertMock: vi.fn(),
confirmDialogMock: vi.fn(),
settingsState: {
settings: {
releaseChannel: "stable" as const,
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: true,
},
},
updateSettings: vi.fn<
(updates: {
daemon?: {
manageBuiltInDaemon?: boolean;
keepRunningAfterQuit?: boolean;
};
}) => Promise<void>
>(),
},
daemonStatusState: {
data: {
status: {
serverId: "desktop",
status: "running" as const,
listen: null,
hostname: null,
pid: 123,
home: "/tmp/paseo",
version: "1.2.3",
desktopManaged: true,
error: null,
},
logs: {
logPath: "/tmp/paseo/daemon.log",
contents: "daemon log",
},
},
isLoading: false,
error: null as string | null,
setStatus: vi.fn(),
refetch: vi.fn(),
},
}));
vi.mock("react-native", () => ({
ActivityIndicator: () => React.createElement("div", { "data-testid": "loading-spinner" }),
Alert: { alert: alertMock },
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
View: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? (factory as (theme: Record<string, unknown>) => unknown)({
spacing: { 1: 4, 2: 8, 3: 12, 6: 24 },
borderRadius: { lg: 12 },
fontSize: { xs: 12, sm: 14 },
colors: {
foreground: "#111",
foregroundMuted: "#666",
palette: { amber: { 500: "#f59e0b" } },
},
})
: factory,
},
useUnistyles: () => ({
theme: {
iconSize: { sm: 14 },
colors: {
foreground: "#111",
foregroundMuted: "#666",
},
},
}),
}));
vi.mock("expo-clipboard", () => ({
setStringAsync: vi.fn(() => Promise.resolve()),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {
Activity: icon("Activity"),
ArrowUpRight: icon("ArrowUpRight"),
Copy: icon("Copy"),
FileText: icon("FileText"),
};
});
vi.mock("@/styles/settings", () => ({
settingsStyles: new Proxy(
{},
{
get: (_target, prop) => String(prop),
},
),
}));
vi.mock("@/screens/settings/settings-section", () => ({
SettingsSection: ({
children,
title,
trailing,
}: {
children?: React.ReactNode;
title: string;
trailing?: React.ReactNode;
}) =>
React.createElement(
"section",
null,
React.createElement("h2", null, title),
trailing,
children,
),
}));
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
children,
visible,
title,
}: {
children?: React.ReactNode;
visible?: boolean;
title?: string;
}) => (visible ? React.createElement("div", { "data-title": title }, children) : null),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
onPress,
disabled,
accessibilityLabel,
}: {
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
accessibilityLabel?: string;
}) =>
React.createElement(
"button",
{ type: "button", onClick: onPress, disabled, "aria-label": accessibilityLabel },
children,
),
}));
vi.mock("@/components/ui/switch", () => ({
Switch: ({
value,
onValueChange,
disabled,
accessibilityLabel,
}: {
value: boolean;
onValueChange?: (next: boolean) => void;
disabled?: boolean;
accessibilityLabel?: string;
}) =>
React.createElement("button", {
type: "button",
role: "switch",
"aria-label": accessibilityLabel,
"aria-checked": value,
disabled,
onClick: () => onValueChange?.(!value),
}),
}));
vi.mock("@/desktop/settings/desktop-settings", () => ({
useDesktopSettings: () => ({
...settingsState,
isLoading: false,
error: null,
}),
}));
vi.mock("@/desktop/hooks/use-daemon-status", () => ({
useDaemonStatus: () => daemonStatusState,
}));
vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: confirmDialogMock,
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(),
}));
vi.mock("@/desktop/updates/desktop-updates", () => ({
isVersionMismatch: vi.fn(() => false),
}));
const daemonCommandMocks = vi.hoisted(() => ({
getCliDaemonStatusMock: vi.fn(),
stopDesktopDaemonMock: vi.fn(),
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
shouldUseDesktopDaemon: vi.fn(() => true),
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
}));
vi.mock("@/utils/app-version", () => ({
resolveAppVersion: vi.fn(() => "1.2.3"),
}));
describe("LocalDaemonSection", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
alertMock.mockReset();
confirmDialogMock.mockReset();
settingsState.settings.daemon.manageBuiltInDaemon = true;
settingsState.settings.daemon.keepRunningAfterQuit = true;
settingsState.updateSettings.mockReset();
settingsState.updateSettings.mockResolvedValue();
daemonStatusState.data.status.status = "running";
daemonStatusState.setStatus.mockReset();
daemonStatusState.refetch.mockReset();
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status,
status: "stopped",
});
daemonCommandMocks.getCliDaemonStatusMock.mockReset();
});
it("renders the daemon toggles as switches reflecting current settings", () => {
const screen = render(<LocalDaemonSection />);
expect(screen.getByText("Keep daemon running after quit")).toBeTruthy();
expect(screen.getByText("Manage built-in daemon")).toBeTruthy();
const keepRunningSwitch = screen.getByRole("switch", {
name: "Keep daemon running after quit",
});
expect(keepRunningSwitch.getAttribute("aria-checked")).toBe("true");
const manageSwitch = screen.getByRole("switch", { name: "Manage built-in daemon" });
expect(manageSwitch.getAttribute("aria-checked")).toBe("true");
});
it("does not render a duplicate restart-daemon control", () => {
const screen = render(<LocalDaemonSection />);
expect(screen.queryByText("Restart daemon")).toBeNull();
expect(screen.queryByText("Start daemon")).toBeNull();
});
it("updates keep-running-after-quit without changing daemon lifecycle", async () => {
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Keep daemon running after quit" }));
await waitFor(() => {
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
keepRunningAfterQuit: false,
},
});
});
expect(confirmDialogMock).not.toHaveBeenCalled();
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
});
it("pauses built-in daemon management and persists the setting through desktop settings", async () => {
confirmDialogMock.mockResolvedValue(true);
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
});
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
manageBuiltInDaemon: false,
},
});
});
});
@@ -1,121 +1,36 @@
import { useCallback, useMemo, useState } from "react";
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native";
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { Switch } from "@/components/ui/switch";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { resolveAppVersion } from "@/utils/app-version";
function getDaemonManagementButtonLabel(isUpdating: boolean, isPaused: boolean): string {
if (isUpdating) return isPaused ? "Resuming..." : "Pausing...";
return isPaused ? "Resume" : "Pause";
}
function getDaemonRestartButtonLabel(
isRestarting: boolean,
daemonStatus: string | undefined,
actionLabel: string,
): string {
if (!isRestarting) return actionLabel;
return daemonStatus === "running" ? "Restarting..." : "Starting...";
}
function useDaemonRestartAction(args: {
showSection: boolean;
daemonStatus: DesktopDaemonStatus | null;
daemonActionLabel: string;
setStatus: (status: DesktopDaemonStatus) => void;
refetch: () => void;
}) {
const { showSection, daemonStatus, daemonActionLabel, setStatus, refetch } = args;
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection || isRestartingDaemon) {
return;
}
void confirmDialog({
title: daemonActionLabel,
message:
daemonStatus?.status === "running"
? "This will restart the built-in daemon. The app will reconnect automatically."
: "This will start the built-in daemon.",
confirmLabel: daemonActionLabel,
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsRestartingDaemon(true);
setStatusMessage(null);
const action =
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
void action()
.then((newStatus) => {
setStatus(newStatus);
setStatusMessage(
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
);
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to change desktop daemon state", error);
const message = error instanceof Error ? error.message : String(error);
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
})
.finally(() => {
setIsRestartingDaemon(false);
});
return;
})
.catch((error) => {
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [
daemonActionLabel,
daemonStatus?.status,
isRestartingDaemon,
refetch,
setStatus,
showSection,
]);
return { isRestartingDaemon, statusMessage, setStatusMessage, handleUpdateLocalDaemon };
}
type DesktopDaemonSettings = DesktopSettings["daemon"];
function useDaemonManagementToggle(args: {
daemonStatus: DesktopDaemonStatus | null;
settings: { manageBuiltInDaemon: boolean };
updateSettings: (next: { manageBuiltInDaemon: boolean }) => Promise<unknown>;
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
setStatus: (status: DesktopDaemonStatus) => void;
setStatusMessage: (message: string | null) => void;
refetch: () => void;
}) {
const { daemonStatus, settings, updateSettings, setStatus, setStatusMessage, refetch } = args;
const { daemonStatus, settings, updateSettings, setStatus, refetch } = args;
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const handleToggleDaemonManagement = useCallback(() => {
@@ -125,12 +40,7 @@ function useDaemonManagementToggle(args: {
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
void updateSettings({ manageBuiltInDaemon: true })
.then(() => {
setStatusMessage("Built-in daemon management resumed.");
return;
})
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
@@ -155,7 +65,6 @@ function useDaemonManagementToggle(args: {
}
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
const stopPromise =
daemonStatus?.status === "running"
@@ -171,7 +80,6 @@ function useDaemonManagementToggle(args: {
})
.then(() => {
refetch();
setStatusMessage("Built-in daemon paused and stopped.");
return;
})
.catch((error) => {
@@ -192,7 +100,6 @@ function useDaemonManagementToggle(args: {
isUpdatingDaemonManagement,
refetch,
setStatus,
setStatusMessage,
settings.manageBuiltInDaemon,
updateSettings,
]);
@@ -200,6 +107,28 @@ function useDaemonManagementToggle(args: {
return { isUpdatingDaemonManagement, handleToggleDaemonManagement };
}
function useKeepRunningAfterQuitToggle(args: {
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
}) {
const { settings, updateSettings } = args;
const [isUpdatingKeepRunningAfterQuit, setIsUpdatingKeepRunningAfterQuit] = useState(false);
const handleToggleKeepRunningAfterQuit = useCallback(() => {
setIsUpdatingKeepRunningAfterQuit(true);
void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit })
.catch((error) => {
console.error("[Settings] Failed to update desktop quit daemon behavior", error);
Alert.alert("Error", "Unable to update the desktop quit daemon behavior.");
})
.finally(() => {
setIsUpdatingKeepRunningAfterQuit(false);
});
}, [settings.keepRunningAfterQuit, updateSettings]);
return { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit };
}
function useDaemonCliStatusModal() {
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
@@ -233,10 +162,6 @@ function useDaemonCliStatusModal() {
});
}, [cliStatusOutput]);
const handleRunCliStatus = useCallback(() => {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
return {
@@ -244,7 +169,7 @@ function useDaemonCliStatusModal() {
isCliStatusModalOpen,
isLoadingCliStatus,
handleCopyCliStatus,
handleRunCliStatus,
handleOpenCliStatus,
handleCloseCliStatusModal,
};
}
@@ -297,7 +222,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
snapPoints={LOGS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
<Text style={styles.logOutput} selectable>
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
@@ -348,20 +273,14 @@ interface DaemonInfoCardProps {
daemonStatusStateText: string;
daemonStatusDetailText: string;
isDaemonManagementPaused: boolean;
playIcon: React.ReactElement;
pauseIcon: React.ReactElement;
rotateIcon: React.ReactElement;
copyIcon: React.ReactElement;
fileTextIcon: React.ReactElement;
activityIcon: React.ReactElement;
copyIcon: ReactElement;
fileTextIcon: ReactElement;
activityIcon: ReactElement;
handleToggleDaemonManagement: () => void;
isUpdatingDaemonManagement: boolean;
daemonActionLabel: string;
daemonActionMessage: string;
statusMessage: string | null;
handleUpdateLocalDaemon: () => void;
isRestartingDaemon: boolean;
daemonStatus: DesktopDaemonStatus | null;
keepRunningAfterQuit: boolean;
handleToggleKeepRunningAfterQuit: () => void;
isUpdatingKeepRunningAfterQuit: boolean;
daemonLogs: { logPath?: string } | null;
handleCopyLogPath: () => void;
handleOpenLogs: () => void;
@@ -374,20 +293,14 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
daemonStatusStateText,
daemonStatusDetailText,
isDaemonManagementPaused,
playIcon,
pauseIcon,
rotateIcon,
copyIcon,
fileTextIcon,
activityIcon,
handleToggleDaemonManagement,
isUpdatingDaemonManagement,
daemonActionLabel,
daemonActionMessage,
statusMessage,
handleUpdateLocalDaemon,
isRestartingDaemon,
daemonStatus,
keepRunningAfterQuit,
handleToggleKeepRunningAfterQuit,
isUpdatingKeepRunningAfterQuit,
daemonLogs,
handleCopyLogPath,
handleOpenLogs,
@@ -400,9 +313,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>
Only the built-in desktop daemon is shown here.
</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -411,44 +322,33 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
</Text>
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={isDaemonManagementPaused ? playIcon : pauseIcon}
onPress={handleToggleDaemonManagement}
<Switch
value={!isDaemonManagementPaused}
onValueChange={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{getDaemonManagementButtonLabel(isUpdatingDaemonManagement, isDaemonManagementPaused)}
</Button>
accessibilityLabel="Manage built-in daemon"
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={rotateIcon}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{getDaemonRestartButtonLabel(isRestartingDaemon, daemonStatus?.status, daemonActionLabel)}
</Button>
<Switch
value={keepRunningAfterQuit}
onValueChange={handleToggleKeepRunningAfterQuit}
disabled={isUpdatingKeepRunningAfterQuit}
accessibilityLabel="Keep daemon running after quit"
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable."}
{daemonLogs?.logPath ?? "Log path unavailable"}
</Text>
</View>
<View style={styles.actionGroup}>
@@ -472,7 +372,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
Runs `paseo daemon status` and shows the output
</Text>
</View>
<Button
@@ -493,7 +393,12 @@ export function LocalDaemonSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const appVersion = resolveAppVersion();
const { settings, updateSettings } = useAppSettings();
const { settings, updateSettings, isLoading: isLoadingSettings } = useDesktopSettings();
const daemonSettings = settings.daemon;
const updateDaemonSettings = useCallback(
(updates: Partial<DesktopDaemonSettings>) => updateSettings({ daemon: updates }),
[updateSettings],
);
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
const daemonStatus = data?.status ?? null;
@@ -504,30 +409,20 @@ export function LocalDaemonSection() {
const daemonStatusStateText =
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon";
const daemonActionMessage =
daemonStatus?.status === "running"
? "Restarts the built-in daemon."
: "Starts the built-in daemon.";
const { isRestartingDaemon, statusMessage, setStatusMessage, handleUpdateLocalDaemon } =
useDaemonRestartAction({
showSection,
daemonStatus,
daemonActionLabel,
setStatus,
refetch,
});
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({
daemonStatus,
settings,
updateSettings,
settings: daemonSettings,
updateSettings: updateDaemonSettings,
setStatus,
setStatusMessage,
refetch,
});
const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } =
useKeepRunningAfterQuitToggle({
settings: daemonSettings,
updateSettings: updateDaemonSettings,
});
const { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal } =
useDaemonLogsModal(daemonLogs);
@@ -537,9 +432,12 @@ export function LocalDaemonSection() {
isCliStatusModalOpen,
isLoadingCliStatus,
handleCopyCliStatus,
handleRunCliStatus,
handleOpenCliStatus,
handleCloseCliStatusModal,
} = useDaemonCliStatusModal();
const handleRunCliStatus = useCallback(() => {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleOpenAdvancedSettings = useCallback(
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
@@ -550,18 +448,6 @@ export function LocalDaemonSection() {
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const playIcon = useMemo(
() => <Play size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
const pauseIcon = useMemo(
() => <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
const rotateIcon = useMemo(
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
const copyIcon = useMemo(
() => <Copy size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
@@ -602,7 +488,7 @@ export function LocalDaemonSection() {
trailing={advancedSettingsButton}
testID="host-page-daemon-lifecycle-card"
>
{isLoading ? (
{isLoading || isLoadingSettings ? (
<View style={LOADING_CARD_STYLE}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
@@ -612,20 +498,14 @@ export function LocalDaemonSection() {
daemonStatusStateText={daemonStatusStateText}
daemonStatusDetailText={daemonStatusDetailText}
isDaemonManagementPaused={isDaemonManagementPaused}
playIcon={playIcon}
pauseIcon={pauseIcon}
rotateIcon={rotateIcon}
copyIcon={copyIcon}
fileTextIcon={fileTextIcon}
activityIcon={activityIcon}
handleToggleDaemonManagement={handleToggleDaemonManagement}
isUpdatingDaemonManagement={isUpdatingDaemonManagement}
daemonActionLabel={daemonActionLabel}
daemonActionMessage={daemonActionMessage}
statusMessage={statusMessage}
handleUpdateLocalDaemon={handleUpdateLocalDaemon}
isRestartingDaemon={isRestartingDaemon}
daemonStatus={daemonStatus}
keepRunningAfterQuit={daemonSettings.keepRunningAfterQuit}
handleToggleKeepRunningAfterQuit={handleToggleKeepRunningAfterQuit}
isUpdatingKeepRunningAfterQuit={isUpdatingKeepRunningAfterQuit}
daemonLogs={daemonLogs}
handleCopyLogPath={handleCopyLogPath}
handleOpenLogs={handleOpenLogs}
@@ -687,11 +567,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
warningCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
@@ -133,9 +133,7 @@ export function IntegrationsSection() {
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Command line</Text>
</View>
<Text style={settingsStyles.rowHint}>
Control and script agents from your terminal.
</Text>
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
@@ -160,7 +158,7 @@ export function IntegrationsSection() {
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI.
Teach your agents to orchestrate through the CLI
</Text>
</View>
{skillsStatus?.installed ? (
@@ -0,0 +1,138 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getIsElectron } from "@/constants/platform";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import type { ReleaseChannel } from "@/hooks/use-settings";
export const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
export interface DesktopSettings {
releaseChannel: ReleaseChannel;
daemon: {
manageBuiltInDaemon: boolean;
keepRunningAfterQuit: boolean;
};
}
export interface DesktopSettingsPatch {
releaseChannel?: ReleaseChannel;
daemon?: Partial<DesktopSettings["daemon"]>;
}
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
releaseChannel: "stable",
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: true,
},
};
export function useDesktopSettings(): {
settings: DesktopSettings;
isLoading: boolean;
error: unknown;
updateSettings: (updates: DesktopSettingsPatch) => Promise<void>;
} {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: DESKTOP_SETTINGS_QUERY_KEY,
queryFn: loadDesktopSettings,
staleTime: Infinity,
gcTime: Infinity,
});
const updateSettings = useCallback(
async (updates: DesktopSettingsPatch) => {
if (!getIsElectron()) {
return;
}
const previous =
queryClient.getQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY) ??
DEFAULT_DESKTOP_SETTINGS;
const next = mergeDesktopSettings(previous, updates);
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, next);
const persisted = await updatePersistedDesktopSettings(updates);
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, persisted);
},
[queryClient],
);
return {
settings: data ?? DEFAULT_DESKTOP_SETTINGS,
isLoading: isPending,
error: error ?? null,
updateSettings,
};
}
export async function loadDesktopSettings(): Promise<DesktopSettings> {
if (!getIsElectron()) {
return DEFAULT_DESKTOP_SETTINGS;
}
return parseDesktopSettings(await invokeDesktopCommand<unknown>("get_desktop_settings"));
}
export async function updatePersistedDesktopSettings(
updates: DesktopSettingsPatch,
): Promise<DesktopSettings> {
if (!getIsElectron()) {
return DEFAULT_DESKTOP_SETTINGS;
}
return parseDesktopSettings(
await invokeDesktopCommand<unknown>("patch_desktop_settings", normalizePatch(updates)),
);
}
export async function migrateLegacyDesktopSettings(input: {
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;
}): Promise<void> {
if (!getIsElectron()) {
return;
}
await invokeDesktopCommand("migrate_legacy_desktop_settings", input);
}
function parseDesktopSettings(raw: unknown): DesktopSettings {
const record = isRecord(raw) ? raw : {};
const daemon = isRecord(record.daemon) ? record.daemon : {};
return {
releaseChannel: record.releaseChannel === "beta" ? "beta" : "stable",
daemon: {
manageBuiltInDaemon:
typeof daemon.manageBuiltInDaemon === "boolean"
? daemon.manageBuiltInDaemon
: DEFAULT_DESKTOP_SETTINGS.daemon.manageBuiltInDaemon,
keepRunningAfterQuit:
typeof daemon.keepRunningAfterQuit === "boolean"
? daemon.keepRunningAfterQuit
: DEFAULT_DESKTOP_SETTINGS.daemon.keepRunningAfterQuit,
},
};
}
function mergeDesktopSettings(
current: DesktopSettings,
updates: DesktopSettingsPatch,
): DesktopSettings {
return {
releaseChannel: updates.releaseChannel ?? current.releaseChannel,
daemon: {
...current.daemon,
...updates.daemon,
},
};
}
function normalizePatch(updates: DesktopSettingsPatch): Record<string, unknown> {
return {
...(updates.releaseChannel ? { releaseChannel: updates.releaseChannel } : {}),
...(updates.daemon ? { daemon: updates.daemon } : {}),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,71 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDesktopAppUpdater } from "./use-desktop-app-updater";
const desktopUpdatesMock = vi.hoisted(() => ({
shouldShowDesktopUpdateSection: vi.fn(() => true),
checkDesktopAppUpdate: vi.fn(async () => ({
hasUpdate: false,
readyToInstall: false,
latestVersion: null,
})),
installDesktopAppUpdate: vi.fn(async () => ({
installed: false,
message: null,
})),
formatVersionWithPrefix: vi.fn((version: string | null) => version ?? "\u2014"),
}));
const settingsState = vi.hoisted(() => ({
releaseChannel: "stable" as "stable" | "beta",
}));
vi.mock("@/desktop/updates/desktop-updates", () => desktopUpdatesMock);
vi.mock("@/desktop/settings/desktop-settings", () => ({
useDesktopSettings: () => ({
settings: {
releaseChannel: settingsState.releaseChannel,
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
},
isLoading: false,
error: null,
updateSettings: vi.fn(async () => {}),
}),
}));
describe("useDesktopAppUpdater", () => {
beforeEach(() => {
settingsState.releaseChannel = "stable";
desktopUpdatesMock.checkDesktopAppUpdate.mockClear();
desktopUpdatesMock.installDesktopAppUpdate.mockClear();
});
it("uses the effective desktop release channel when checking for updates", async () => {
settingsState.releaseChannel = "beta";
renderHook(() => useDesktopAppUpdater());
await waitFor(() => {
expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({
releaseChannel: "beta",
});
});
});
it("uses the effective desktop release channel when installing updates", async () => {
settingsState.releaseChannel = "beta";
const { result } = renderHook(() => useDesktopAppUpdater());
await act(async () => {
await result.current.installUpdate();
});
expect(desktopUpdatesMock.installDesktopAppUpdate).toHaveBeenCalledWith({
releaseChannel: "beta",
});
});
});
@@ -7,7 +7,7 @@ import {
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates";
import { useAppSettings } from "@/hooks/use-settings";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
export type DesktopAppUpdateStatus =
| "idle"
@@ -84,8 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const { settings: desktopSettings } = useDesktopSettings();
const releaseChannel = desktopSettings.releaseChannel;
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -78,6 +78,7 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "codex",
status: "ready",
enabled: true,
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
@@ -107,6 +108,7 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
@@ -53,6 +53,7 @@ describe("useAgentFormState", () => {
{
provider: "zai",
status: "ready",
enabled: true,
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
@@ -69,6 +70,7 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
@@ -544,6 +546,7 @@ describe("useAgentFormState", () => {
{
provider: "codex",
status: "loading",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
@@ -552,6 +555,7 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
@@ -596,11 +600,12 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
it("ignores disabled ready providers when resolving selectable defaults", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
@@ -609,6 +614,106 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
const resolved = __private__.resolveFormState(
undefined,
{ provider: "claude" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
selectableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("auto");
});
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
const snapshotProviderStates = [
{ provider: entries[0]?.provider, enabled: entries[0]?.enabled },
{ provider: entries[1]?.provider, enabled: entries[1]?.enabled },
];
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
expect(providerDefinitionIds).toEqual(["codex", "claude"]);
expect(snapshotProviderStates).toEqual([
{ provider: "codex", enabled: true },
{ provider: "claude", enabled: false },
]);
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
const matchingProviders = new Set(
args.snapshotEntries
.filter((entry) => args.statuses.has(entry.status))
.filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
.map((entry) => entry.provider),
);
+280
View File
@@ -0,0 +1,280 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import { useProjects } from "./use-projects";
interface MockDaemonClient {
fetchWorkspaces: ReturnType<typeof vi.fn>;
}
const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => {
const state = {
hosts: [
{ serverId: "local", label: "Local" },
{ serverId: "laptop", label: "Laptop" },
],
snapshots: new Map<string, { connectionStatus: string }>(),
};
const clients = new Map<string, { fetchWorkspaces: ReturnType<typeof vi.fn> }>();
return {
hostState: state,
clientsByServerId: clients,
runtimeStore: {
getClient: (serverId: string) => clients.get(serverId) ?? null,
getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null,
},
};
});
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => runtimeStore,
useHosts: () => hostState.hosts,
}));
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function renderProjectsHook() {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(() => useProjects(), { wrapper });
}
function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient {
return {
fetchWorkspaces: vi.fn(async () => {
if (entries instanceof Error) {
throw entries;
}
return {
requestId: "req-workspaces",
entries,
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
};
}),
};
}
function workspace(input: {
id: string;
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
}): WorkspaceDescriptorPayload {
return {
id: input.id,
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectRootPath: input.cwd,
workspaceDirectory: input.cwd,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
afterEach(() => {
clientsByServerId.clear();
hostState.hosts = [
{ serverId: "local", label: "Local" },
{ serverId: "laptop", label: "Laptop" },
];
hostState.snapshots = new Map<string, { connectionStatus: string }>();
});
describe("useProjects", () => {
it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
hostState.snapshots.set("laptop", { connectionStatus: "online" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "z-main",
projectKey: "remote:github.com/acme/zeta",
projectName: "acme/zeta",
cwd: "/repo/zeta",
remoteUrl: "https://github.com/acme/zeta.git",
}),
]),
);
clientsByServerId.set(
"laptop",
makeClient([
workspace({
id: "a-main",
projectKey: "remote:github.com/acme/alpha",
projectName: "acme/alpha",
cwd: "/repo/alpha",
remoteUrl: "https://github.com/acme/alpha.git",
}),
]),
);
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
expect(result.current.projects.map((project) => project.projectName)).toEqual([
"acme/alpha",
"acme/zeta",
]);
});
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
hostState.snapshots.set("laptop", { connectionStatus: "online" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
]),
);
clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable")));
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
expect(result.current.hostErrors).toEqual([
{
serverId: "laptop",
serverName: "Laptop",
message: "laptop unavailable",
},
]);
});
it("skips disconnected hosts silently without surfacing them as failures", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
hostState.snapshots.set("laptop", { connectionStatus: "offline" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
]),
);
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hostErrors).toEqual([]);
expect(result.current.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
});
it("returns only the stable public project and host entry shapes", async () => {
hostState.snapshots.set("local", { connectionStatus: "online" });
clientsByServerId.set(
"local",
makeClient([
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
]),
);
const { result } = renderProjectsHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hiddenUnsupportedRemoteCount",
"hostCount",
"hosts",
"onlineHostCount",
"projectKey",
"projectName",
"totalWorkspaceCount",
]);
expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
"gitRuntime",
"githubRuntime",
"isOnline",
"repoRoot",
"serverId",
"serverName",
"workspaceCount",
"workspaces",
]);
expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
"currentBranch",
"id",
"name",
"status",
"workspaceKind",
]);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export const projectsQueryKey = ["projects"] as const;
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface UseProjectsResult {
projects: ProjectSummary[];
hiddenUnsupportedRemoteCount: number;
hostErrors: ProjectHostError[];
isLoading: boolean;
isFetching: boolean;
refetch: () => void;
}
interface FetchAllWorkspaceDescriptorsInput {
client: Pick<DaemonClient, "fetchWorkspaces">;
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function fetchAllWorkspaceDescriptors(
input: FetchAllWorkspaceDescriptorsInput,
): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: [{ key: "name", direction: "asc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return entries;
}
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const hostInputs = useMemo(
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
[hosts],
);
const projectsQuery = useQuery({
queryKey: projectsQueryKey,
queryFn: async () => {
const results = await Promise.all(
hostInputs.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: null,
};
}
try {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: await fetchAllWorkspaceDescriptors({ client }),
},
error: null,
};
} catch (error) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
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,
};
},
});
return {
projects: projectsQuery.data?.projects ?? [],
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
hostErrors: projectsQuery.data?.hostErrors ?? [],
isLoading: projectsQuery.isLoading,
isFetching: projectsQuery.isFetching,
refetch: () => {
void projectsQuery.refetch();
},
};
}
@@ -101,6 +101,7 @@ function codexEntry(
return {
provider: "codex",
status,
enabled: true,
...(models ? { models } : {}),
};
}
+106 -6
View File
@@ -5,15 +5,33 @@ const asyncStorageMock = vi.hoisted(() => ({
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
const electronRuntimeState = vi.hoisted(() => ({
isElectron: false,
}));
const desktopSettingsMock = vi.hoisted(() => ({
loadDesktopSettings: vi.fn<() => Promise<unknown>>(),
migrateLegacyDesktopSettings: vi.fn<(_: unknown) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
vi.mock("@/desktop/host", () => ({
isElectronRuntime: () => electronRuntimeState.isElectron,
}));
vi.mock("@/desktop/settings/desktop-settings", () => desktopSettingsMock);
describe("use-settings", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
electronRuntimeState.isElectron = false;
desktopSettingsMock.loadDesktopSettings.mockReset();
desktopSettingsMock.migrateLegacyDesktopSettings.mockReset();
});
it("defaults built-in daemon management to enabled when storage is empty", async () => {
@@ -26,7 +44,7 @@ describe("use-settings", () => {
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
JSON.stringify(mod.DEFAULT_APP_SETTINGS),
JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS),
);
});
@@ -50,7 +68,7 @@ describe("use-settings", () => {
expect(result.releaseChannel).toBe("stable");
});
it("loads persisted built-in daemon management state", async () => {
it("ignores renderer-owned daemon management state outside Electron", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
@@ -66,14 +84,13 @@ describe("use-settings", () => {
expect(result).toEqual({
theme: "light",
manageBuiltInDaemon: false,
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
releaseChannel: "stable",
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("loads persisted beta release channel", async () => {
it("ignores renderer-owned release channel outside Electron", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
@@ -86,6 +103,89 @@ describe("use-settings", () => {
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("beta");
expect(result.releaseChannel).toBe("stable");
});
it("keeps legacy AsyncStorage migration for client settings only", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return null;
}
if (key === "@paseo:settings") {
return JSON.stringify({
theme: "dark",
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
}
return null;
});
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadAppSettingsFromStorage();
expect(result).toEqual({
theme: "dark",
sendBehavior: "interrupt",
});
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
JSON.stringify(result),
);
});
it("migrates legacy desktop-owned settings through Electron before reading effective settings", async () => {
electronRuntimeState.isElectron = true;
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
theme: "light",
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
}
return null;
});
desktopSettingsMock.migrateLegacyDesktopSettings.mockResolvedValue();
desktopSettingsMock.loadDesktopSettings.mockResolvedValue({
releaseChannel: "beta",
daemon: {
manageBuiltInDaemon: false,
keepRunningAfterQuit: true,
},
});
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(desktopSettingsMock.migrateLegacyDesktopSettings).toHaveBeenCalledWith({
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
expect(result).toEqual({
theme: "light",
sendBehavior: "interrupt",
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
});
it("skips desktop IPC when loading effective settings outside Electron", async () => {
asyncStorageMock.getItem.mockResolvedValue(
JSON.stringify({
theme: "light",
}),
);
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result).toEqual({
theme: "light",
sendBehavior: "interrupt",
manageBuiltInDaemon: true,
releaseChannel: "stable",
});
});
});
+170 -29
View File
@@ -1,46 +1,66 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
DEFAULT_DESKTOP_SETTINGS,
loadDesktopSettings,
migrateLegacyDesktopSettings,
useDesktopSettings,
} from "@/desktop/settings/desktop-settings";
import { isElectronRuntime } from "@/desktop/host";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
export interface AppSettings {
theme: ThemeName | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
}
export interface Settings extends AppSettings {
manageBuiltInDaemon: boolean;
releaseChannel: ReleaseChannel;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
};
export const DEFAULT_APP_SETTINGS: Settings = {
...DEFAULT_CLIENT_SETTINGS,
manageBuiltInDaemon: true,
releaseChannel: "stable",
};
export interface UseAppSettingsReturn {
settings: AppSettings;
isLoading: boolean;
error: unknown | null;
error: unknown;
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
resetSettings: () => Promise<void>;
}
export interface UseSettingsReturn {
settings: Settings;
isLoading: boolean;
error: unknown;
updateSettings: (updates: Partial<Settings>) => Promise<void>;
resetSettings: () => Promise<void>;
}
export function useAppSettings(): UseAppSettingsReturn {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: APP_SETTINGS_QUERY_KEY,
queryFn: loadSettingsFromStorage,
queryFn: loadAppSettingsFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
@@ -49,7 +69,7 @@ export function useAppSettings(): UseAppSettingsReturn {
async (updates: Partial<AppSettings>) => {
try {
const prev =
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS;
const next = { ...prev, ...updates };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
@@ -63,7 +83,7 @@ export function useAppSettings(): UseAppSettingsReturn {
const resetSettings = useCallback(async () => {
try {
const next = { ...DEFAULT_APP_SETTINGS };
const next = { ...DEFAULT_CLIENT_SETTINGS };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
} catch (err) {
@@ -73,7 +93,7 @@ export function useAppSettings(): UseAppSettingsReturn {
}, [queryClient]);
return {
settings: data ?? DEFAULT_APP_SETTINGS,
settings: data ?? DEFAULT_CLIENT_SETTINGS,
isLoading: isPending,
error: error ?? null,
updateSettings,
@@ -81,51 +101,172 @@ export function useAppSettings(): UseAppSettingsReturn {
};
}
export async function loadSettingsFromStorage(): Promise<AppSettings> {
export function useSettings(): UseSettingsReturn {
const appSettings = useAppSettings();
const desktopSettings = useDesktopSettings();
const updateSettings = useCallback(
async (updates: Partial<Settings>) => {
const appUpdates: Partial<AppSettings> = {};
if (updates.theme !== undefined) {
appUpdates.theme = updates.theme;
}
if (updates.sendBehavior !== undefined) {
appUpdates.sendBehavior = updates.sendBehavior;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
}
if (isElectronRuntime()) {
const desktopUpdates: Parameters<typeof desktopSettings.updateSettings>[0] = {};
if (updates.manageBuiltInDaemon !== undefined) {
desktopUpdates.daemon = {
manageBuiltInDaemon: updates.manageBuiltInDaemon,
};
}
if (updates.releaseChannel !== undefined) {
desktopUpdates.releaseChannel = updates.releaseChannel;
}
if (Object.keys(desktopUpdates).length > 0) {
promises.push(desktopSettings.updateSettings(desktopUpdates));
}
}
await Promise.all(promises);
},
[appSettings, desktopSettings],
);
const resetSettings = useCallback(async () => {
const resets: Promise<void>[] = [appSettings.resetSettings()];
if (isElectronRuntime()) {
resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS));
}
await Promise.all(resets);
}, [appSettings, desktopSettings]);
return {
settings: {
...DEFAULT_APP_SETTINGS,
...appSettings.settings,
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
releaseChannel: desktopSettings.settings.releaseChannel,
},
isLoading: appSettings.isLoading || desktopSettings.isLoading,
error: appSettings.error ?? desktopSettings.error,
updateSettings,
resetSettings,
};
}
export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
try {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>;
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
parsed.theme = DEFAULT_APP_SETTINGS.theme;
}
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
}
return { ...DEFAULT_APP_SETTINGS, ...parsed };
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) };
}
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
if (legacyStored) {
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
const next = {
...DEFAULT_APP_SETTINGS,
...DEFAULT_CLIENT_SETTINGS,
...pickAppSettingsFromLegacy(legacyParsed),
} satisfies AppSettings;
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
return next;
}
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS));
return DEFAULT_APP_SETTINGS;
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS));
return DEFAULT_CLIENT_SETTINGS;
} catch (error) {
console.error("[AppSettings] Failed to load settings:", error);
throw error;
}
}
export async function loadSettingsFromStorage(): Promise<Settings> {
const legacyDesktopSettings = isElectronRuntime()
? await loadLegacyDesktopSettingsFromStorage()
: null;
const appSettings = await loadAppSettingsFromStorage();
if (!isElectronRuntime()) {
return {
...DEFAULT_APP_SETTINGS,
...appSettings,
};
}
if (legacyDesktopSettings) {
await migrateLegacyDesktopSettings(legacyDesktopSettings);
}
const desktopSettings = await loadDesktopSettings();
return {
...DEFAULT_APP_SETTINGS,
...appSettings,
manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon,
releaseChannel: desktopSettings.releaseChannel,
};
}
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme as AppSettings["theme"];
}
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
result.sendBehavior = stored.sendBehavior;
}
return result;
}
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme;
}
if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
}
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
result.releaseChannel = legacy.releaseChannel;
}
return result;
}
export const useSettings = useAppSettings;
async function loadLegacyDesktopSettingsFromStorage(): Promise<{
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;
} | null> {
const stored = await loadRendererSettingsPayload();
if (!stored) {
return null;
}
const result: {
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;
} = {};
if (typeof stored.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
}
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
result.releaseChannel = stored.releaseChannel;
}
return Object.keys(result).length > 0 ? result : null;
}
async function loadRendererSettingsPayload(): Promise<Record<string, unknown> | null> {
const current = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (current) {
return JSON.parse(current) as Record<string, unknown>;
}
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
if (!legacy) {
return null;
}
return JSON.parse(legacy) as Record<string, unknown>;
}
@@ -21,6 +21,7 @@ export interface SidebarWorkspaceEntry {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKey: string;
projectRootPath?: string;
workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"];
@@ -57,6 +58,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
projectKey: input.project.projectKey,
projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined,
projectKind: input.project.projectKind,
@@ -77,6 +79,7 @@ export function createSidebarWorkspaceEntry(input: {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
@@ -282,6 +282,72 @@ function updateControllerSnapshot(
).updateSnapshot(patch);
}
function makeSingleConnectionHost(serverId: string): HostProfile {
return makeHost({
serverId,
connections: [
{
id: `direct:${serverId}:6767`,
type: "directTcp",
endpoint: `${serverId}:6767`,
},
],
preferredConnectionId: `direct:${serverId}:6767`,
});
}
function createOfflineOnlyStore(): HostRuntimeStore {
return new HostRuntimeStore({
deps: {
createClient: () => {
throw new Error("startup host tests drive snapshots directly");
},
connectToDaemon: async () => {
throw new Error("offline");
},
getClientId: async () => "cid_test_runtime",
},
});
}
function setStoreHosts(store: HostRuntimeStore, hosts: HostProfile[]): void {
(
store as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
function markStoreHostOnline(
store: HostRuntimeStore,
serverId: string,
lastOnlineAt: string,
): void {
const controller = (
store as unknown as {
controllers: Map<string, HostRuntimeController>;
}
).controllers.get(serverId);
if (!controller) {
throw new Error(`Missing controller for ${serverId}`);
}
(
store as unknown as {
lastConnectionStatusByServer: Map<string, HostRuntimeSnapshot["connectionStatus"]>;
}
).lastConnectionStatusByServer.set(serverId, "online");
updateControllerSnapshot(controller, {
connectionStatus: "online",
hasEverLoadedAgentDirectory: true,
lastOnlineAt,
});
(
store as unknown as {
emit: (serverId: string) => void;
}
).emit(serverId);
}
function makeProbeMap(
entries: Array<
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
@@ -1158,6 +1224,113 @@ describe("HostRuntimeController", () => {
});
describe("HostRuntimeStore", () => {
it("resolves startup host immediately when the preferred host is first online", async () => {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:00.000Z");
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
startup.cancel();
setStoreHosts(store, []);
});
it("waits through grace for the preferred host after a different host comes online first", async () => {
vi.useFakeTimers();
try {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
await vi.advanceTimersByTimeAsync(1_000);
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:01.000Z");
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
startup.cancel();
setStoreHosts(store, []);
} finally {
vi.useRealTimers();
}
});
it("falls back to the first online host when preferred does not arrive during grace", async () => {
vi.useFakeTimers();
try {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
await vi.advanceTimersByTimeAsync(2_000);
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
startup.cancel();
setStoreHosts(store, []);
} finally {
vi.useRealTimers();
}
});
it("resolves the first online host immediately when no preferred host is set", async () => {
const store = createOfflineOnlyStore();
setStoreHosts(store, [makeSingleConnectionHost("srv_other")]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: null });
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
startup.cancel();
setStoreHosts(store, []);
});
it("resolves null when startup has no hosts to wait for", async () => {
const store = createOfflineOnlyStore();
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
await expect(startup.promise).resolves.toBeNull();
startup.cancel();
});
it("resolves null using the connection online timeout when hosts exist but none come online", async () => {
vi.useFakeTimers();
try {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
let resolved = false;
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
startup.promise.then(() => {
resolved = true;
return undefined;
});
await vi.advanceTimersByTimeAsync(4_999);
expect(resolved).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(startup.promise).resolves.toBeNull();
startup.cancel();
setStoreHosts(store, []);
} finally {
vi.useRealTimers();
}
});
it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({
connections: [
+107 -24
View File
@@ -70,6 +70,10 @@ export interface HostRuntimeSnapshot {
clientGeneration: number;
}
export interface StartupHostResolution {
serverId: string;
}
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
@@ -1151,6 +1155,8 @@ const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const STARTUP_HOST_GRACE_MS = 2_000;
const STARTUP_NO_HOST_ONLINE_TIMEOUT_MS = 5_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore {
@@ -1739,38 +1745,115 @@ export class HostRuntimeStore {
};
}
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
let unsubscribe: (() => void) | null = null;
const isAnyOnline = (): boolean => {
for (const host of this.hosts) {
const snapshot = this.getSnapshot(host.serverId);
if (snapshot?.connectionStatus === "online") return true;
getEarliestOnlineHostServerId(): string | null {
let earliestServerId: string | null = null;
let earliestOnlineAt: string | null = null;
for (const host of this.hosts) {
const snapshot = this.getSnapshot(host.serverId);
if (!isHostRuntimeConnected(snapshot) || !snapshot?.lastOnlineAt) continue;
if (!earliestOnlineAt || snapshot.lastOnlineAt < earliestOnlineAt) {
earliestOnlineAt = snapshot.lastOnlineAt;
earliestServerId = host.serverId;
}
return false;
}
return earliestServerId;
}
waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): {
promise: Promise<StartupHostResolution | null>;
cancel: () => void;
} {
let unsubscribe: (() => void) | null = null;
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
let noHostOnlineTimeout: ReturnType<typeof setTimeout> | null = null;
let fallbackServerId: string | null = null;
let settled = false;
const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
const cleanup = (): void => {
if (graceTimeout) {
clearTimeout(graceTimeout);
graceTimeout = null;
}
if (noHostOnlineTimeout) {
clearTimeout(noHostOnlineTimeout);
noHostOnlineTimeout = null;
}
unsubscribe?.();
unsubscribe = null;
};
const promise = new Promise<void>((resolve) => {
if (isAnyOnline()) {
resolve();
return;
}
unsubscribe = this.subscribeAll(() => {
if (isAnyOnline()) {
unsubscribe?.();
unsubscribe = null;
resolve();
const promise = new Promise<StartupHostResolution | null>((resolve) => {
const settle = (result: StartupHostResolution | null): void => {
if (settled) {
return;
}
});
settled = true;
cleanup();
resolve(result);
};
const getPreferredOnlineServerId = (): string | null => {
if (!normalizedPreferredServerId) {
return null;
}
return isHostRuntimeConnected(this.getSnapshot(normalizedPreferredServerId))
? normalizedPreferredServerId
: null;
};
const evaluate = (): void => {
if (this.hosts.length === 0) {
settle(null);
return;
}
const preferredOnlineServerId = getPreferredOnlineServerId();
if (preferredOnlineServerId) {
settle({ serverId: preferredOnlineServerId });
return;
}
const earliestOnlineServerId = this.getEarliestOnlineHostServerId();
if (!earliestOnlineServerId) {
if (!noHostOnlineTimeout) {
noHostOnlineTimeout = setTimeout(() => {
settle(null);
}, STARTUP_NO_HOST_ONLINE_TIMEOUT_MS);
}
return;
}
if (noHostOnlineTimeout) {
clearTimeout(noHostOnlineTimeout);
noHostOnlineTimeout = null;
}
if (!normalizedPreferredServerId) {
settle({ serverId: earliestOnlineServerId });
return;
}
if (!fallbackServerId) {
fallbackServerId = earliestOnlineServerId;
graceTimeout = setTimeout(() => {
const latestPreferredOnlineServerId = getPreferredOnlineServerId();
settle({
serverId: latestPreferredOnlineServerId ?? fallbackServerId ?? earliestOnlineServerId,
});
}, normalizedGraceMs);
}
};
unsubscribe = this.subscribeAll(evaluate);
evaluate();
});
return {
promise,
cancel: () => {
unsubscribe?.();
unsubscribe = null;
},
cancel: cleanup,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
const { theme, projectsState, navigate } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 20 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" as const, medium: "500" as const },
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
opacity: { 50: 0.5 },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surfaceSidebarHover: "#1a1a1a",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#444",
accent: "#0a84ff",
palette: { red: { 300: "#ff6b6b" } },
},
},
projectsState: {
current: {
projects: [],
hostErrors: [],
hiddenUnsupportedRemoteCount: 0,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
} as UseProjectsResult,
},
navigate: vi.fn(),
}));
vi.mock("react-native", () => {
const passthrough = ({
children,
testID,
accessibilityLabel,
accessibilityRole,
onPress,
onHoverIn,
onHoverOut,
...rest
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
accessibilityRole?: string;
onPress?: (event: { stopPropagation: () => void }) => void;
onHoverIn?: () => void;
onHoverOut?: () => void;
} & Record<string, unknown>) => {
const dataAttrs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rest)) {
if (key.startsWith("data-")) {
dataAttrs[key] = value;
}
}
return React.createElement(
"div",
{
role: accessibilityRole,
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: onPress
? (event: React.MouseEvent) => {
onPress({ stopPropagation: () => event.stopPropagation() });
}
: undefined,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
...dataAttrs,
},
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
);
};
return {
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
Pressable: passthrough,
Image: ({ source }: { source?: { uri?: string } }) =>
React.createElement("img", { src: source?.uri ?? "" }),
Platform: { OS: "web" },
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
ChevronRight: icon("ChevronRight"),
MoreVertical: icon("MoreVertical"),
ExternalLink: icon("ExternalLink"),
Pencil: icon("Pencil"),
FolderGit2: icon("FolderGit2"),
};
});
vi.mock("expo-router", () => ({
router: { navigate },
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: ({ size }: { size?: string | number }) =>
React.createElement("span", {
"data-testid": "projects-loading-spinner",
"data-size": size,
}),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu" }, children),
DropdownMenuTrigger: ({
children,
accessibilityLabel,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: (event: React.MouseEvent) => event.stopPropagation(),
},
typeof children === "function"
? children({ pressed: false, hovered: false, open: false })
: children,
),
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu-content" }, children),
DropdownMenuItem: ({
children,
onSelect,
testID,
}: {
children?: React.ReactNode;
onSelect?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"data-testid": testID,
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
onSelect?.();
},
},
children,
),
}));
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current,
}));
vi.mock("@/hooks/use-project-icon-query", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
}));
import ProjectsScreen from "./projects-screen";
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
return {
id: "ws-1",
name: "main",
workspaceKind: "directory",
status: "done",
currentBranch: "main",
...overrides,
};
}
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
workspaceCount: 1,
workspaces: [workspaceSummary()],
...overrides,
};
}
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
const hosts = overrides.hosts ?? [hostEntry()];
const totalWorkspaceCount =
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
return {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: "https://github.com/acme/app",
hiddenUnsupportedRemoteCount: 0,
...overrides,
};
}
function setProjectsState(overrides: Partial<UseProjectsResult>) {
projectsState.current = {
projects: [],
hostErrors: [],
hiddenUnsupportedRemoteCount: 0,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
...overrides,
};
}
function findRow(container: HTMLElement, projectKey: string): HTMLElement {
const row = container.querySelector<HTMLElement>(`[data-testid="project-row-${projectKey}"]`);
if (!row) throw new Error(`Expected row for ${projectKey}`);
return row;
}
describe("ProjectsScreen", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
setProjectsState({});
navigate.mockReset();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
act(() => {
root?.render(<ProjectsScreen view={view} />);
});
}
it("renders a row whose visible content is the project name only", () => {
setProjectsState({
projects: [
project({
projectName: "acme/app",
hosts: [hostEntry({ serverName: "alpha", workspaceCount: 5 })],
}),
],
});
render({ kind: "projects" });
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
expect(rows.length).toBe(1);
expect(container?.textContent).toContain("acme/app");
expect(container?.textContent).not.toContain("workspace");
expect(container?.textContent).not.toContain("offline");
expect(container?.textContent).not.toContain("github.com");
});
it("navigates to the project detail route when the row is pressed", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
const row = findRow(container!, "remote:github.com/acme/app");
act(() => {
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
});
it("does not render a kebab menu on the row", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
expect(
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
).toBeNull();
});
it("renders a centered loading spinner before the first response", () => {
setProjectsState({ isLoading: true, projects: [] });
render({ kind: "projects" });
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
});
it("renders the empty state when there are no projects", () => {
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
render({ kind: "projects" });
expect(container?.textContent).toContain("No projects yet");
});
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
render({ kind: "projects" });
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
});
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
const hostErrors: ProjectHostError[] = [
{ serverId: "a", serverName: "alpha", message: "timed out" },
{ serverId: "b", serverName: "beta", message: "unreachable" },
];
setProjectsState({
projects: [project()],
hostErrors,
});
render({ kind: "projects" });
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("alpha");
expect(banner?.textContent).toContain("beta");
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
});
it("highlights the selected row when the active view targets a project", () => {
setProjectsState({
projects: [
project({ projectKey: "remote:github.com/acme/app" }),
project({
projectKey: "remote:github.com/acme/other",
projectName: "acme/other",
githubUrl: "https://github.com/acme/other",
}),
],
});
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
const selected = findRow(container!, "remote:github.com/acme/app");
const other = findRow(container!, "remote:github.com/acme/other");
expect(selected.getAttribute("data-selected")).toBe("true");
expect(other.getAttribute("data-selected")).toBe("false");
});
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", workspaceCount: 3 }),
hostEntry({ serverId: "b", serverName: "beta", workspaceCount: 2 }),
],
}),
],
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
});
render({ kind: "projects" });
const html = container?.innerHTML.toLowerCase() ?? "";
expect(html).not.toContain("checkout");
});
});
@@ -0,0 +1,213 @@
import { useCallback, useMemo } from "react";
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; projectKey: string };
}
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
if (isLoading && projects.length === 0) {
return (
<View style={styles.centered} testID="projects-list">
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
</View>
);
}
if (projects.length === 0) {
const message =
hiddenUnsupportedRemoteCount > 0
? "Non-GitHub remote projects aren't supported yet"
: "No projects yet";
return (
<View style={styles.centered} testID="projects-list">
<Text style={styles.emptyText}>{message}</Text>
</View>
);
}
return (
<View testID="projects-list">
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
<View style={settingsStyles.card}>
{projects.map((project, index) => (
<ProjectRow
key={project.projectKey}
project={project}
isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey}
/>
))}
</View>
</View>
);
}
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
return (
<View style={styles.errorsBanner} testID="projects-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{`Couldn't load projects from host ${error.serverName}: ${error.message}`}
</Text>
))}
</View>
);
}
interface ProjectRowProps {
project: ProjectSummary;
isFirst: boolean;
isSelected: boolean;
}
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
const { theme } = useUnistyles();
const { hosts, projectKey, projectName } = project;
const leadingHost = hosts[0];
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
!isFirst && settingsStyles.rowBorder,
styles.row,
isSelected && styles.rowSelected,
hovered && !isSelected && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst, isSelected],
);
return (
<Pressable
style={rowStyle}
onPress={handleNavigate}
accessibilityRole="button"
accessibilityLabel={`Edit ${projectName}`}
testID={`project-row-${projectKey}`}
data-selected={isSelected ? "true" : "false"}
>
<View style={styles.rowMain}>
<View style={styles.leading}>
<ProjectRowIcon host={leadingHost} projectName={projectName} />
</View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{projectName}
</Text>
</View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
function ProjectRowIcon({
host,
projectName,
}: {
host: ProjectHostEntry | undefined;
projectName: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({
serverId: host?.serverId ?? "",
cwd: host?.repoRoot ?? "",
});
const iconDataUri =
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
if (iconDataUri) {
return <Image source={imageSource} style={styles.iconImage} />;
}
return (
<View style={styles.iconFallback}>
<Text style={styles.iconFallbackText}>{initial}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorsBanner: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[3],
gap: theme.spacing[1],
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
row: {
gap: theme.spacing[3],
},
rowMain: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
rowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
leading: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
iconImage: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
},
iconFallback: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
iconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
}));
+93 -18
View File
@@ -27,13 +27,20 @@ import {
Shield,
Puzzle,
Plus,
FolderGit2,
} from "lucide-react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
import {
useAppSettings,
useSettings,
type AppSettings,
type SendBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import { THEME_SWATCHES } from "@/styles/theme";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -64,11 +71,14 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
buildHostOpenProjectRoute,
buildHostWorkspaceRoute,
buildProjectsSettingsRoute,
buildSettingsHostRoute,
buildSettingsSectionRoute,
type SettingsSectionSlug,
@@ -82,7 +92,9 @@ import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-ac
export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string };
| { kind: "host"; serverId: string }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -354,7 +366,7 @@ function getUpdateButtonLabel(
}
function DesktopAppUpdateRow() {
const { settings, updateSettings } = useAppSettings();
const { settings, updateSettings } = useSettings();
const {
isDesktopApp,
statusText,
@@ -384,7 +396,7 @@ function DesktopAppUpdateRow() {
}, [checkForUpdates, isDesktopApp]);
const handleReleaseChannelChange = useCallback(
(releaseChannel: AppSettings["releaseChannel"]) => {
(releaseChannel: EffectiveSettings["releaseChannel"]) => {
void updateSettings({ releaseChannel });
},
[updateSettings],
@@ -538,6 +550,37 @@ function SidebarSectionButton({
);
}
interface SidebarProjectsButtonProps {
isSelected: boolean;
onSelect: () => void;
}
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
const { theme } = useUnistyles();
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onSelect}
testID="settings-projects"
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<FolderGit2
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
Projects
</Text>
</Pressable>
);
}
interface SidebarHostItemProps {
serverId: string;
label: string;
@@ -584,6 +627,7 @@ interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
layout: "desktop" | "mobile";
@@ -593,6 +637,7 @@ function SettingsSidebar({
view,
onSelectSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
layout,
@@ -620,6 +665,7 @@ function SettingsSidebar({
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
return (
@@ -649,6 +695,7 @@ function SettingsSidebar({
onSelect={onSelectSection}
/>
))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
@@ -812,6 +859,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router],
);
const handleSelectProjects = useCallback(() => {
const target = buildProjectsSettingsRoute();
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
}, [isCompactLayout, router]);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
@@ -838,17 +894,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]);
const handleBackToWorkspace = useCallback(() => {
if (!isCompactLayout) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
}
if (router.canGoBack()) {
router.back();
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
if (anyOnlineServerId) {
@@ -856,7 +906,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return;
}
router.replace("/");
}, [anyOnlineServerId, isCompactLayout, router]);
}, [anyOnlineServerId, router]);
const detailHeader = ((): {
title: string;
@@ -877,6 +927,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (!item) return null;
return { title: item.label, Icon: item.icon };
}
if (view.kind === "project" || view.kind === "projects") {
return { title: "Projects", Icon: FolderGit2 };
}
return null;
})();
@@ -884,6 +937,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (view.kind === "host") {
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
}
if (view.kind === "section") {
switch (view.section) {
case "general":
@@ -958,6 +1017,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="mobile"
@@ -968,14 +1028,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
);
}
// Mobile detail: full-screen content with a back header that returns to the list.
// Mobile detail: full-screen content with a back header. Project detail uses
// an app-level back (out of settings, to the workspace) since the in-body
// "Back to projects" ghost button handles list-level back; other detail views
// step back to the settings root.
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
if (isCompactLayout) {
return (
<View style={styles.container}>
<BackHeader
title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory}
onBack={handleBackToRoot}
onBack={detailBackHandler}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
@@ -995,6 +1059,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="desktop"
@@ -1088,6 +1153,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
placeholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[8],
},
placeholderText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
const desktopStyles = StyleSheet.create((theme) => ({
@@ -17,7 +17,7 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons
import { confirmDialog } from "@/utils/confirm-dialog";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
@@ -29,11 +29,6 @@ import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section
const RESTART_CONFIRMATION_MESSAGE =
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
const INJECT_TOOLS_OPTIONS = [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
];
function formatHostConnectionLabel(connection: HostConnection): string {
if (connection.type === "relay") {
return `Relay (${connection.relayEndpoint})`;
@@ -579,7 +574,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Restart daemon</Text>
<Text style={settingsStyles.rowHint}>
Restarts the daemon process. The app will reconnect automatically.
Restarts the daemon process. The app will reconnect automatically
</Text>
</View>
<Button
@@ -602,10 +597,10 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { config, patchConfig } = useDaemonConfig(serverId);
const handleValueChange = useCallback(
(value: string) => {
(next: boolean) => {
void patchConfig({
mcp: {
injectIntoAgents: value === "on",
injectIntoAgents: next,
},
});
},
@@ -623,11 +618,10 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
Automatically inject Paseo MCP tools into new agents
</Text>
</View>
<SegmentedControl
size="sm"
value={config?.mcp.injectIntoAgents === false ? "off" : "on"}
<Switch
value={config?.mcp.injectIntoAgents !== false}
onValueChange={handleValueChange}
options={INJECT_TOOLS_OPTIONS}
accessibilityLabel="Inject Paseo tools"
/>
</View>
</View>
@@ -0,0 +1,347 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { MutableDaemonConfig } from "@server/shared/messages";
const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
iconSize: { sm: 14, md: 20 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
borderRadius: { lg: 8 },
opacity: { 50: 0.5 },
colors: {
surface1: "#111",
surface2: "#222",
surface3: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
accent: "#0a84ff",
statusSuccess: "#00ff00",
statusWarning: "#ff9500",
statusDanger: "#ff0000",
palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
},
},
snapshotState: {
entries: undefined as ProviderSnapshotEntry[] | undefined,
isLoading: false,
isRefreshing: false,
},
configState: {
config: null as MutableDaemonConfig | null,
},
patchConfigMock: vi.fn(async () => undefined),
refreshMock: vi.fn(async () => {}),
}));
vi.mock("react-native", () => ({
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
Pressable: ({
children,
onPress,
onHoverIn,
onHoverOut,
accessibilityRole,
accessibilityLabel,
disabled,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
onPress?: (event: React.MouseEvent) => void;
onHoverIn?: () => void;
onHoverOut?: () => void;
accessibilityRole?: string;
accessibilityLabel?: string;
disabled?: boolean;
testID?: string;
}) =>
React.createElement(
"div",
{
role: accessibilityRole,
"aria-label": accessibilityLabel,
"aria-disabled": disabled ? "true" : undefined,
"data-testid": testID,
onClick: disabled ? undefined : onPress,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
},
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
),
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {
ChevronRight: icon("ChevronRight"),
RotateCw: icon("RotateCw"),
};
});
vi.mock("@/components/ui/switch", () => ({
Switch: ({
value,
onValueChange,
disabled,
accessibilityLabel,
testID,
}: {
value: boolean;
onValueChange?: (next: boolean) => void;
disabled?: boolean;
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement("div", {
role: "switch",
"aria-checked": value ? "true" : "false",
"aria-disabled": disabled ? "true" : undefined,
"aria-label": accessibilityLabel,
"data-testid": testID ?? "provider-switch",
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
if (disabled) return;
onValueChange?.(!value);
},
}),
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: (provider: string) => () =>
React.createElement("span", { "data-icon": `provider-${provider}` }),
}));
vi.mock("@/components/provider-diagnostic-sheet", () => ({
ProviderDiagnosticSheet: ({ provider }: { provider: string }) =>
React.createElement("div", {
"data-testid": "provider-diagnostic-sheet",
"data-provider": provider,
}),
}));
vi.mock("@/hooks/use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries: snapshotState.entries,
isLoading: snapshotState.isLoading,
isFetching: false,
isRefreshing: snapshotState.isRefreshing,
error: null,
supportsSnapshot: true,
refresh: refreshMock,
refetchIfStale: vi.fn(),
}),
}));
vi.mock("@/hooks/use-daemon-config", () => ({
useDaemonConfig: () => ({
config: configState.config,
isLoading: false,
patchConfig: patchConfigMock,
}),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => true,
}));
import { ProvidersSection } from "./providers-section";
const claudeEntry: ProviderSnapshotEntry = {
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Claude Code",
defaultModeId: null,
modes: [],
models: [
{ provider: "claude", id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ provider: "claude", id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ provider: "claude", id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
],
};
const disabledCodexEntry: ProviderSnapshotEntry = {
provider: "codex",
status: "unavailable",
enabled: false,
label: "Codex",
description: "OpenAI Codex",
defaultModeId: null,
modes: [],
};
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return { mcp: { injectIntoAgents: false }, providers };
}
function descendants(el: HTMLElement): HTMLElement[] {
return Array.from(el.querySelectorAll<HTMLElement>("*"));
}
function indexOfMatches(nodes: HTMLElement[], selector: string): number {
return nodes.findIndex((node) => node.matches(selector));
}
function indexOfText(nodes: HTMLElement[], text: string): number {
return nodes.findIndex((node) => node.textContent?.trim() === text);
}
describe("ProvidersSection", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
snapshotState.entries = undefined;
snapshotState.isLoading = false;
snapshotState.isRefreshing = false;
configState.config = null;
patchConfigMock.mockReset();
patchConfigMock.mockResolvedValue(undefined);
refreshMock.mockReset();
refreshMock.mockResolvedValue(undefined);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function render(): void {
act(() => {
root?.render(<ProvidersSection serverId="server-1" />);
});
}
function findRow(accessibilityLabel: string): HTMLElement {
const row = container?.querySelector<HTMLElement>(
`[role="button"][aria-label="${accessibilityLabel}"]`,
);
if (!row) throw new Error(`Expected row with aria-label "${accessibilityLabel}"`);
return row;
}
it("renders the disabled provider with its server-provided label in snapshot order", () => {
snapshotState.entries = [claudeEntry, disabledCodexEntry];
configState.config = makeConfig({ codex: { enabled: false } });
render();
const rows = Array.from(
container?.querySelectorAll<HTMLElement>('[role="button"][aria-label$="provider details"]') ??
[],
);
expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([
"Claude provider details",
"Codex provider details",
]);
const codexRow = findRow("Codex provider details");
const codexNodes = descendants(codexRow);
expect(indexOfText(codexNodes, "Codex")).toBeGreaterThanOrEqual(0);
expect(indexOfText(codexNodes, "codex")).toBe(-1);
expect(indexOfText(codexNodes, "Disabled")).toBeGreaterThanOrEqual(0);
});
it("composes the row as chevron, icon, label, status, model count, then switch", () => {
snapshotState.entries = [claudeEntry];
configState.config = makeConfig();
render();
const row = findRow("Claude provider details");
const nodes = descendants(row);
const chevron = indexOfMatches(nodes, '[data-icon="ChevronRight"]');
const icon = indexOfMatches(nodes, '[data-icon="provider-claude"]');
const label = indexOfText(nodes, "Claude");
const status = indexOfText(nodes, "Available");
const modelCount = indexOfText(nodes, "3 models");
const switchEl = indexOfMatches(nodes, '[role="switch"]');
expect(chevron).toBeGreaterThanOrEqual(0);
expect(icon).toBeGreaterThan(chevron);
expect(label).toBeGreaterThan(icon);
expect(status).toBeGreaterThan(label);
expect(modelCount).toBeGreaterThan(status);
expect(switchEl).toBeGreaterThan(modelCount);
});
it("opens the diagnostic sheet when the outer row is pressed for a disabled provider", () => {
snapshotState.entries = [disabledCodexEntry];
configState.config = makeConfig({ codex: { enabled: false } });
render();
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
const row = findRow("Codex provider details");
act(() => {
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]');
expect(sheet).not.toBeNull();
expect(sheet?.getAttribute("data-provider")).toBe("codex");
});
it("toggles the provider enabled flag through patchConfig when the switch is pressed", () => {
snapshotState.entries = [claudeEntry];
configState.config = makeConfig();
render();
const row = findRow("Claude provider details");
const switchEl = row.querySelector<HTMLElement>('[role="switch"]');
expect(switchEl).not.toBeNull();
expect(switchEl?.getAttribute("aria-checked")).toBe("true");
act(() => {
switchEl?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(patchConfigMock).toHaveBeenCalledTimes(1);
expect(patchConfigMock).toHaveBeenCalledWith({
providers: { claude: { enabled: false } },
});
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
});
});
@@ -1,83 +1,176 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { RotateCw } from "lucide-react-native";
import { ChevronRight, RotateCw } from "lucide-react-native";
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
function getProviderStatusLabel(status: string): string {
if (status === "ready") return "Available";
if (status === "error") return "Error";
if (status === "loading") return "Loading...";
return "Not installed";
type StatusTone = "success" | "warning" | "danger" | "muted" | "loading";
interface ProviderStatus {
tone: StatusTone;
label: string;
modelCount: number | null;
}
function getProviderStatusVariant(status: string): "success" | "error" | "muted" {
if (status === "ready") return "success";
if (status === "error") return "error";
return "muted";
function getProviderStatus(status: string, enabled: boolean, modelCount: number): ProviderStatus {
if (!enabled) return { tone: "muted", label: "Disabled", modelCount: null };
if (status === "loading") return { tone: "loading", label: "Loading", modelCount: null };
if (status === "error") return { tone: "danger", label: "Error", modelCount: null };
if (status === "ready") {
return {
tone: "success",
label: "Available",
modelCount: modelCount > 0 ? modelCount : null,
};
}
return { tone: "warning", label: "Not installed", modelCount: null };
}
interface ProviderRowProps {
def: ProviderDefinition;
entry: ProviderEntry | undefined;
entry: ProviderEntry;
enabled: boolean;
isToggling: boolean;
isFirst: boolean;
onPress: (providerId: string) => void;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
}
function ProviderRow({ def, entry, isFirst, onPress }: ProviderRowProps) {
function ProviderRow({
def,
entry,
enabled,
isToggling,
isFirst,
onPress,
onToggleEnabled,
}: ProviderRowProps) {
const { theme } = useUnistyles();
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
const providerError =
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
enabled &&
entry.status === "error" &&
typeof entry.error === "string" &&
entry.error.trim().length > 0
? entry.error.trim()
: null;
const modelCount = entry?.models?.length ?? 0;
const modelCount = entry.models?.length ?? 0;
const providerStatus = getProviderStatus(entry.status, enabled, modelCount);
const handlePress = useCallback(() => onPress(def.id), [def.id, onPress]);
const rowStyle = useMemo(
() => [settingsStyles.row, !isFirst && settingsStyles.rowBorder],
const handlePress = useCallback(() => {
onPress(def.id);
}, [def.id, onPress]);
const handleToggleValueChange = useCallback(
(value: boolean) => {
onToggleEnabled(def.id, value);
},
[def.id, onToggleEnabled],
);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
!isFirst && settingsStyles.rowBorder,
styles.row,
hovered && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst],
);
return (
<Pressable style={rowStyle} onPress={handlePress} accessibilityRole="button">
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>{def.label}</Text>
</View>
{providerError ? (
<Text style={styles.errorText} numberOfLines={3}>
{providerError}
</Text>
) : null}
{status === "ready" && modelCount > 0 ? (
<Text style={settingsStyles.rowHint}>
{modelCount === 1 ? "1 model" : `${modelCount} models`}
</Text>
) : null}
</View>
<StatusBadge
label={getProviderStatusLabel(status)}
variant={getProviderStatusVariant(status)}
/>
<Pressable
style={rowStyle}
onPress={handlePress}
accessibilityRole="button"
accessibilityLabel={`${def.label} provider details`}
>
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<>
<View style={styles.rowContent}>
<ChevronRight
size={theme.iconSize.sm}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foreground} />
<View style={styles.textColumn}>
<View style={styles.titleRow}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{def.label}
</Text>
<Text style={styles.separator}>·</Text>
<StatusIndicator status={providerStatus} />
</View>
{providerError ? (
<Text style={styles.errorText} numberOfLines={3}>
{providerError}
</Text>
) : null}
</View>
</View>
<Switch
value={enabled}
onValueChange={handleToggleValueChange}
disabled={isToggling}
accessibilityLabel={`Enable ${def.label}`}
/>
</>
)}
</Pressable>
);
}
function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["theme"]): string {
switch (tone) {
case "success":
return theme.colors.statusSuccess;
case "warning":
return theme.colors.statusWarning;
case "danger":
return theme.colors.statusDanger;
default:
return theme.colors.foregroundMuted;
}
}
function StatusIndicator({ status }: { status: ProviderStatus }) {
const { theme } = useUnistyles();
const dotStyle = useMemo(
() => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
[status.tone, theme],
);
return (
<View style={styles.statusRow}>
{status.tone === "loading" ? (
<LoadingSpinner size={10} color={theme.colors.foregroundMuted} />
) : (
<View style={dotStyle} />
)}
<Text style={styles.statusLabel}>{status.label}</Text>
{status.modelCount !== null ? (
<>
<Text style={styles.separator}>·</Text>
<Text style={styles.statusLabel}>
{status.modelCount === 1 ? "1 model" : `${status.modelCount} models`}
</Text>
</>
) : null}
</View>
);
}
export interface ProvidersSectionProps {
serverId: string;
}
@@ -86,8 +179,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(serverId);
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
const providerDefinitions = buildProviderDefinitions(entries);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const providerRefreshInFlight =
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = serverId.length > 0;
@@ -97,6 +193,22 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
}, [refresh]);
const handleCloseDiagnostic = useCallback(() => setDiagnosticProvider(null), []);
const handleToggleEnabled = useCallback(
async (providerId: string, enabled: boolean) => {
setPendingProviderId(providerId);
try {
await patchConfig({ providers: { [providerId]: { enabled } } });
} catch (error) {
Alert.alert(
"Unable to update provider",
error instanceof Error ? error.message : String(error),
);
} finally {
setPendingProviderId((current) => (current === providerId ? null : current));
}
},
[patchConfig],
);
const refreshAction = useMemo(
() =>
@@ -146,17 +258,24 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : null}
{hasServer && isConnected && !isLoading ? (
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
<View style={settingsStyles.card}>
{providerDefinitions.map((def, index) => (
<ProviderRow
key={def.id}
def={def}
entry={entries?.find((e) => e.provider === def.id)}
isFirst={index === 0}
onPress={setDiagnosticProvider}
/>
))}
{providerDefinitions.map((def, index) => {
const entry = entries?.find((candidate) => candidate.provider === def.id);
if (!entry) return null;
return (
<ProviderRow
key={def.id}
def={def}
entry={entry}
enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id}
isFirst={index === 0}
onPress={setDiagnosticProvider}
onToggleEnabled={handleToggleEnabled}
/>
);
})}
</View>
) : null}
</SettingsSection>
@@ -185,11 +304,49 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
row: {
gap: theme.spacing[3],
minHeight: 56,
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
rowContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
textColumn: {
flex: 1,
minWidth: 0,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
separator: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
@@ -1,15 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
import { ScrollView, Text, View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import MaskedView from "@react-native-masked-view/masked-view";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
interface StartupSplashScreenProps {
@@ -23,6 +33,10 @@ interface StartupSplashScreenProps {
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const DOCS_URL = "https://paseo.sh/docs";
const LOGO_SIZE = 96;
const SHIMMER_PEAK_WIDTH = 120;
const SHIMMER_DURATION_MS = 1800;
function openGithubIssue(): void {
void openExternalUrl(GITHUB_ISSUE_URL);
}
@@ -31,6 +45,141 @@ function openDocs(): void {
void openExternalUrl(DOCS_URL);
}
const WEB_SPLASH_SHIMMER_KEYFRAME_ID = "paseo-splash-shimmer-keyframes";
const WEB_SPLASH_SHIMMER_ANIMATION_NAME = "paseo-splash-shimmer";
const WEB_SPLASH_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_SPLASH_SHIMMER_ANIMATION_NAME} {
0% {
background-position: -${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0;
}
100% {
background-position: ${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0;
}
}
`;
let webSplashShimmerRegistered = false;
function ensureWebSplashShimmerKeyframes() {
if (isNative) {
return;
}
if (webSplashShimmerRegistered) {
return;
}
const existing = document.getElementById(WEB_SPLASH_SHIMMER_KEYFRAME_ID);
if (existing) {
webSplashShimmerRegistered = true;
return;
}
const styleElement = document.createElement("style");
styleElement.id = WEB_SPLASH_SHIMMER_KEYFRAME_ID;
styleElement.textContent = WEB_SPLASH_SHIMMER_KEYFRAME_CSS;
document.head.appendChild(styleElement);
webSplashShimmerRegistered = true;
}
function LogoShimmer() {
const { theme } = useUnistyles();
if (isWeb) {
return <WebLogoShimmer color={theme.colors.foreground} />;
}
return <NativeLogoShimmer color={theme.colors.foreground} />;
}
function WebLogoShimmer({ color }: { color: string }) {
useEffect(() => {
ensureWebSplashShimmerKeyframes();
}, []);
const shimmerStyle = useMemo(
() => ({
width: LOGO_SIZE,
height: LOGO_SIZE,
WebkitMaskImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='${LOGO_SIZE}' height='${LOGO_SIZE}' viewBox='0 0 700 700'><path fill='black' d='M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z'/></svg>`)}")`,
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
WebkitMaskPosition: "center",
background: `linear-gradient(90deg, ${color} 0%, ${color}88 40%, ${color}FF 50%, ${color}88 60%, ${color} 100%)`,
backgroundSize: `${LOGO_SIZE + SHIMMER_PEAK_WIDTH * 2}px ${LOGO_SIZE}px`,
animationName: WEB_SPLASH_SHIMMER_ANIMATION_NAME,
animationDuration: `${SHIMMER_DURATION_MS}ms`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
}),
[color],
);
return <View style={shimmerStyle as never} />;
}
function NativeLogoShimmer({ color }: { color: string }) {
const shimmerTranslateX = useSharedValue(-SHIMMER_PEAK_WIDTH);
useEffect(() => {
shimmerTranslateX.value = -SHIMMER_PEAK_WIDTH;
shimmerTranslateX.value = withRepeat(
withTiming(LOGO_SIZE + SHIMMER_PEAK_WIDTH, {
duration: SHIMMER_DURATION_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [shimmerTranslateX]);
const peakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
}));
const trackStyle = useMemo(
() => [styles.nativeShimmerTrack, { width: LOGO_SIZE, height: LOGO_SIZE }],
[],
);
const peakCombinedStyle = useMemo(
() => [styles.nativeShimmerPeak, peakStyle, { width: SHIMMER_PEAK_WIDTH, height: LOGO_SIZE }],
[peakStyle],
);
const maskElement = useMemo(
() => (
<View style={styles.shimmerMask}>
<PaseoLogo size={LOGO_SIZE} color="#000000" />
</View>
),
[],
);
return (
<MaskedView style={trackStyle} maskElement={maskElement}>
<View style={trackStyle}>
<View style={styles.nativeShimmerBase}>
<PaseoLogo size={LOGO_SIZE} color={color} />
</View>
<Animated.View style={peakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id="splashShimmer" x1="0" y1="0" x2="1" y2="0">
<Stop offset="0" stopColor="#FFFFFF" stopOpacity="0" />
<Stop offset="0.5" stopColor="#FFFFFF" stopOpacity="0.4" />
<Stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill="url(#splashShimmer)" />
</Svg>
</Animated.View>
</View>
</MaskedView>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "relative",
@@ -41,10 +190,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[8],
paddingVertical: theme.spacing[8],
},
containerError: {
justifyContent: "flex-start",
paddingTop: theme.spacing[16],
},
errorScreen: {
position: "relative",
flex: 1,
@@ -68,12 +213,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[8],
paddingTop: theme.spacing[16],
},
centeredContent: {
alignItems: "center",
justifyContent: "center",
maxWidth: 520,
width: "100%",
},
errorContent: {
alignItems: "stretch",
maxWidth: 720,
@@ -84,43 +223,11 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-start",
},
title: {
marginTop: theme.spacing[8],
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
titleError: {
textAlign: "left",
},
subtitleRow: {
marginTop: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
progressSteps: {
marginTop: theme.spacing[4],
gap: theme.spacing[3],
width: "100%",
},
progressStepRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
subtitle: {
marginTop: theme.spacing[8],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
subtitleInline: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
errorDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -167,10 +274,29 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
flexWrap: "wrap",
},
shimmerMask: {
width: LOGO_SIZE,
height: LOGO_SIZE,
alignItems: "center",
justifyContent: "center",
},
nativeShimmerTrack: {
overflow: "hidden",
},
nativeShimmerBase: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
nativeShimmerPeak: {
position: "absolute",
top: 0,
bottom: 0,
},
}));
const TITLE_ERROR_STYLE = [styles.title, styles.titleError];
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle();
@@ -186,9 +312,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const phase = bootstrapState?.phase;
const isError = phase === "error";
const isSimpleSplash = bootstrapState === undefined;
const isError = bootstrapState?.phase === "error";
useEffect(() => {
if (!isError) {
@@ -229,23 +353,6 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
};
}, [isError]);
let progressSteps: { key: string; label: string; status: "active" | "complete" }[];
if (phase === "starting-daemon") {
progressSteps = [
{ key: "starting-daemon", label: "Starting local server...", status: "active" },
];
} else if (phase === "connecting") {
progressSteps = [
{ key: "starting-daemon", label: "Started local server", status: "complete" },
{ key: "connecting", label: "Connecting to local server...", status: "active" },
];
} else {
progressSteps = [
{ key: "starting-daemon", label: "Started local server", status: "complete" },
{ key: "connecting", label: "Connected to local server", status: "complete" },
];
}
const logsText = useMemo(() => {
if (isLoadingLogs) {
return "Loading daemon logs...";
@@ -283,36 +390,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
[theme.colors.palette.white],
);
if (isSimpleSplash) {
return (
<View style={styles.container}>
<TitlebarDragRegion />
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Connecting…</Text>
</View>
);
}
if (!isError) {
return (
<View style={styles.container}>
<TitlebarDragRegion />
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<View style={styles.progressSteps}>
{progressSteps.map((step) => (
<View key={step.key} style={styles.progressStepRow}>
{step.status === "complete" ? (
<Check size={18} color={theme.colors.success} />
) : (
<ActivityIndicator color={theme.colors.accent} />
)}
<Text style={styles.subtitleInline}>{step.label}</Text>
</View>
))}
</View>
</View>
<LogoShimmer />
</View>
);
}
@@ -328,7 +410,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={TITLE_ERROR_STYLE}>Something went wrong</Text>
<Text style={styles.title}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
@@ -1,9 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
}));
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function installWindowStub(pathname: string) {
const windowStub = {
location: {
@@ -75,6 +94,10 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
describe("navigation active workspace store", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
});
afterEach(() => {
@@ -173,4 +196,58 @@ describe("navigation active workspace store", () => {
workspaceId: "workspace-a",
});
});
it("hydrates the last workspace route selection from storage on startup", async () => {
const storedSelection = createDeferred<string | null>();
asyncStorageMock.getItem.mockReturnValue(storedSelection.promise);
installWindowStub("/open-project");
const store = await import("@/stores/navigation-active-workspace-store");
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(false);
const hydration = store.hydrateLastNavigationWorkspaceRouteSelection();
storedSelection.resolve(JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }));
await hydration;
expect(asyncStorageMock.getItem).toHaveBeenCalledWith(
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
);
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("hydrates empty and corrupt workspace route storage as null", async () => {
installWindowStub("/open-project");
asyncStorageMock.getItem.mockResolvedValueOnce(null);
let store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
vi.resetModules();
asyncStorageMock.getItem.mockResolvedValueOnce("{not json");
store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("persists valid last workspace route selections", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
);
});
});
@@ -1,3 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMemo, useSyncExternalStore } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import {
@@ -38,8 +39,13 @@ interface NavigationObserverRef {
} | null;
}
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
let snapshot: ActiveWorkspaceSelection | null = null;
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
let isLastWorkspaceRouteSelectionLoaded = false;
let lastWorkspaceRouteSelectionRevision = 0;
let lastWorkspaceRouteSelectionHydrationPromise: Promise<void> | null = null;
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
const listeners = new Set<() => void>();
@@ -54,13 +60,87 @@ function getSnapshot(): ActiveWorkspaceSelection | null {
return snapshot;
}
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
return;
}
snapshot = next;
for (const listener of listeners) {
listener();
notifyListeners();
}
function parseStoredLastWorkspaceRouteSelection(
stored: string | null,
): ActiveWorkspaceSelection | null {
if (!stored) {
return null;
}
try {
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
} catch {
return null;
}
}
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
const selection = input as Record<string, unknown>;
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch(
() => {},
);
}
function setLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
const normalized = normalizeActiveWorkspaceSelection(next);
if (!normalized) {
return;
}
if (
lastWorkspaceRouteSelection?.serverId === normalized.serverId &&
lastWorkspaceRouteSelection.workspaceId === normalized.workspaceId
) {
return;
}
lastWorkspaceRouteSelectionRevision += 1;
lastWorkspaceRouteSelection = normalized;
persistLastWorkspaceRouteSelection(normalized);
}
async function readLastWorkspaceRouteSelectionFromStorage(hydrationRevision: number) {
try {
const stored = await AsyncStorage.getItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY);
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = parseStoredLastWorkspaceRouteSelection(stored);
}
} catch {
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = null;
}
} finally {
isLastWorkspaceRouteSelectionLoaded = true;
notifyListeners();
}
}
@@ -170,7 +250,7 @@ export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverR
const route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
lastWorkspaceRouteSelection = routeState.selection;
setLastWorkspaceRouteSelection(routeState.selection);
if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null;
@@ -197,6 +277,22 @@ export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelec
return lastWorkspaceRouteSelection;
}
export function getIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return isLastWorkspaceRouteSelectionLoaded;
}
export function hydrateLastNavigationWorkspaceRouteSelection(): Promise<void> {
if (lastWorkspaceRouteSelectionHydrationPromise) {
return lastWorkspaceRouteSelectionHydrationPromise;
}
const hydrationRevision = lastWorkspaceRouteSelectionRevision;
lastWorkspaceRouteSelectionHydrationPromise =
readLastWorkspaceRouteSelectionFromStorage(hydrationRevision);
return lastWorkspaceRouteSelectionHydrationPromise;
}
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
nextWorkspaceRouteSelectionOverride = next;
}
@@ -223,6 +319,14 @@ export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return useSyncExternalStore(
subscribe,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
);
}
export function useIsNavigationWorkspaceSelected(input: {
serverId: string | null;
workspaceId: string | null;
@@ -243,6 +347,8 @@ export function useIsNavigationWorkspaceSelected(input: {
);
}
void hydrateLastNavigationWorkspaceRouteSelection();
export function useIsNavigationProjectActive(input: {
serverId: string | null;
workspaceIds: readonly string[];
@@ -115,6 +115,50 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).toEqual([]);
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
projectId: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
});
expect(workspace.project).toEqual({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
});
describe("mergeWorkspaces", () => {
+6
View File
@@ -119,6 +119,9 @@ export interface WorkspaceDescriptor {
status: WorkspaceDescriptorPayload["status"];
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptorPayload["scripts"];
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
project?: ProjectPlacementPayload;
}
export function normalizeWorkspaceDescriptor(
@@ -136,6 +139,9 @@ export function normalizeWorkspaceDescriptor(
status: payload.status,
diffStat: payload.diffStat ?? null,
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
gitRuntime: payload.gitRuntime,
githubRuntime: payload.githubRuntime,
project: payload.project,
};
}
@@ -24,6 +24,7 @@ interface MountedTerminal {
host: HTMLDivElement;
root: HTMLDivElement;
runtime: TerminalEmulatorRuntime;
inputs: string[];
sizes: TerminalSize[];
}
@@ -65,9 +66,13 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
document.body.appendChild(root);
const sizes: TerminalSize[] = [];
const inputs: string[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
onInput: (data) => {
inputs.push(data);
},
onResize: (size) => {
sizes.push(size);
},
@@ -84,7 +89,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
},
});
const mounted = { host, root, runtime, sizes };
const mounted = { host, root, runtime, inputs, sizes };
mountedTerminals.push(mounted);
return mounted;
}
@@ -157,6 +162,22 @@ describe("terminal emulator runtime in a real browser", () => {
expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]);
});
it("suppresses non-identity terminal query responses as PTY input", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
const querySequences = ["\x1b[5n", "\x1b[6n", "\x1b[?6n", "\x1b[1$p", "\x1b[?1$p"];
for (const sequence of querySequences) {
mounted.runtime.write({ text: sequence });
}
await nextFrame();
expect(mounted.inputs).toEqual([]);
});
it("replays snapshots without synchronously resetting the visible terminal", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
@@ -237,11 +237,14 @@ export class TerminalEmulatorRuntime {
}
});
// Suppress terminal query responses — the server-side headless xterm handles these.
// Without this, xterm.js generates DA/CPR responses via onData that feed back
// to the PTY as visible text.
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
// Suppress cursor/status query responses whose async browser replies can race
// with foreground app exit and feed back to the shell as visible text. DA is
// intentionally left to xterm so attached TUIs can identify the terminal.
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
terminal.parser.registerCsiHandler({ intermediates: "$", final: "p" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", intermediates: "$", final: "p" }, () => true);
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { extractToolCallFilePath } from "./extract-tool-call-file-path";
describe("extractToolCallFilePath", () => {
it("returns filePath for read/edit/write", () => {
expect(extractToolCallFilePath({ type: "read", filePath: "/a.ts" })).toBe("/a.ts");
expect(extractToolCallFilePath({ type: "edit", filePath: "/b.ts" })).toBe("/b.ts");
expect(extractToolCallFilePath({ type: "write", filePath: "/c.ts" })).toBe("/c.ts");
});
it("returns null for empty filePath", () => {
expect(extractToolCallFilePath({ type: "read", filePath: "" })).toBeNull();
});
it.each([
["cat ~/file.md", "~/file.md"],
["wc -l ~/.paseo/plans/projects-settings-page.md", "~/.paseo/plans/projects-settings-page.md"],
["head -n 20 src/index.ts", "src/index.ts"],
["tail -f /var/log/x.log", "/var/log/x.log"],
["less ./README.md", "./README.md"],
["stat /tmp/foo", "/tmp/foo"],
])("matches shell command %s", (command, expected) => {
expect(extractToolCallFilePath({ type: "shell", command })).toBe(expected);
});
it.each([
"cat a.ts | grep foo",
"cat a.ts > b.ts",
"cat a.ts b.ts",
"echo hi",
"ls /tmp",
"rm -rf /tmp",
])("returns null for non-matching shell %s", (command) => {
expect(extractToolCallFilePath({ type: "shell", command })).toBeNull();
});
it("returns null for unrelated detail types", () => {
expect(extractToolCallFilePath({ type: "search", query: "foo" })).toBeNull();
expect(extractToolCallFilePath({ type: "unknown", input: null, output: null })).toBeNull();
expect(extractToolCallFilePath(undefined)).toBeNull();
});
});
@@ -0,0 +1,70 @@
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
const SHELL_FILE_COMMANDS = new Set([
"cat",
"bat",
"less",
"more",
"head",
"tail",
"wc",
"nl",
"tac",
"od",
"xxd",
"file",
"stat",
"column",
"md5",
"md5sum",
"sha1sum",
"sha256sum",
"shasum",
]);
const SHELL_OPERATOR_PATTERN = /[|><&;`$()]/;
const SHORT_FLAG_PATTERN = /^-[a-zA-Z]$/;
function extractFromShellCommand(command: string): string | null {
const trimmed = command.trim();
if (!trimmed || SHELL_OPERATOR_PATTERN.test(trimmed)) {
return null;
}
const tokens = trimmed.split(/\s+/);
if (tokens.length < 2) {
return null;
}
if (!SHELL_FILE_COMMANDS.has(tokens[0])) {
return null;
}
const last = tokens[tokens.length - 1];
if (last.startsWith("-")) {
return null;
}
if (tokens.length > 2) {
const prev = tokens[tokens.length - 2];
if (!prev.startsWith("-")) {
const prevPrev = tokens[tokens.length - 3];
if (!prevPrev || !SHORT_FLAG_PATTERN.test(prevPrev)) {
return null;
}
}
}
return last;
}
export function extractToolCallFilePath(detail: ToolCallDetail | undefined): string | null {
if (!detail) {
return null;
}
switch (detail.type) {
case "read":
case "edit":
case "write":
return detail.filePath || null;
case "shell":
return extractFromShellCommand(detail.command);
default:
return null;
}
}
@@ -4,6 +4,8 @@ import {
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment,
@@ -133,3 +135,28 @@ describe("workspace route parsing", () => {
expect(decodeWorkspaceIdFromPathSegment(encoded)).toBe("team/setup:id#1");
});
});
describe("projects settings routes", () => {
it("buildProjectsSettingsRoute returns /settings/projects", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
});
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
);
});
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
);
});
it("project keys round-trip through decodeURIComponent", () => {
const projectKey = "remote:github.com/acme/app";
const route = buildProjectSettingsRoute(projectKey);
const segment = route.slice("/settings/projects/".length);
expect(decodeURIComponent(segment)).toBe(projectKey);
});
});
+12
View File
@@ -403,6 +403,18 @@ export function buildSettingsHostRoute(serverId: string) {
return `/settings/hosts/${encodeSegment(normalized)}` as const;
}
export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
}
export function buildProjectSettingsRoute(projectKey: string) {
const normalized = trimNonEmpty(projectKey);
if (!normalized) {
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
}
return `/settings/projects/${encodeSegment(normalized)}` as const;
}
export function mapPathnameToServer(pathname: string, nextServerId: string) {
const normalized = trimNonEmpty(nextServerId);
if (!normalized) {
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
function emptyDraft(): ProjectConfigDraft {
return {
setupText: "",
setupOriginalKind: "missing",
teardownText: "",
teardownOriginalKind: "missing",
scripts: [],
};
}
describe("configToDraft", () => {
it("returns an empty draft for null config", () => {
expect(configToDraft(null)).toEqual(emptyDraft());
});
it("renders a string lifecycle command as a single textarea text and remembers the kind", () => {
const draft = configToDraft({
worktree: { setup: "npm install" },
});
expect(draft.setupText).toBe("npm install");
expect(draft.setupOriginalKind).toBe("string");
expect(draft.teardownText).toBe("");
expect(draft.teardownOriginalKind).toBe("missing");
});
it("renders an array lifecycle command as newline-separated text", () => {
const draft = configToDraft({
worktree: { teardown: ["docker compose down", "rm -rf .cache"] },
});
expect(draft.teardownText).toBe("docker compose down\nrm -rf .cache");
expect(draft.teardownOriginalKind).toBe("array");
});
it("converts a scripts record into draft rows with stable local ids", () => {
const draft = configToDraft({
scripts: {
dev: { type: "long-running", command: "npm run dev", port: 3000 },
build: { command: ["npm", "run", "build"] },
},
});
expect(draft.scripts).toHaveLength(2);
const [devRow, buildRow] = draft.scripts;
expect(devRow.name).toBe("dev");
expect(devRow.commandText).toBe("npm run dev");
expect(devRow.commandOriginalKind).toBe("string");
expect(devRow.type).toBe("long-running");
expect(devRow.portText).toBe("3000");
expect(devRow.id).toBeTruthy();
expect(buildRow.name).toBe("build");
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
expect(buildRow.commandOriginalKind).toBe("array");
expect(buildRow.portText).toBe("");
expect(buildRow.id).not.toBe(devRow.id);
});
});
describe("applyDraftToConfig", () => {
it("preserves the original string kind when editing an existing setup field", () => {
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
const draft = configToDraft(base);
draft.setupText = "npm install\nnpm run prepare";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toBe("npm install\nnpm run prepare");
});
it("preserves the original array kind when editing an existing teardown field", () => {
const base: PaseoConfigRaw = {
worktree: { teardown: ["docker compose down"] },
};
const draft = configToDraft(base);
draft.teardownText = "docker compose down\nrm -rf .cache";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
});
it("writes a string for a newly added lifecycle field with one non-empty line", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.setupText = "npm install";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toBe("npm install");
});
it("writes an array for a newly added lifecycle field with multiple non-empty lines", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.setupText = "npm install\nnpm run prepare";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
});
it("omits a lifecycle field whose draft text is empty", () => {
const base: PaseoConfigRaw = { worktree: { setup: "npm install" } };
const draft = configToDraft(base);
draft.setupText = "";
const next = applyDraftToConfig({ draft, base });
expect(next.worktree?.setup).toBeUndefined();
});
it("preserves unknown top-level, worktree, and script entry fields on round-trip", () => {
const base = {
worktree: {
setup: "npm install",
terminals: [{ name: "dev", command: "npm run dev" }],
customWorktreeField: "keep",
},
scripts: {
dev: {
type: "long-running",
command: "npm run dev",
port: 3000,
customScriptField: { nested: true },
},
},
customTopLevel: "preserved",
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const next = applyDraftToConfig({ draft, base });
expect((next as unknown as Record<string, unknown>).customTopLevel).toBe("preserved");
expect((next.worktree as unknown as Record<string, unknown>).customWorktreeField).toBe("keep");
expect((next.worktree as unknown as Record<string, unknown>).terminals).toEqual([
{ name: "dev", command: "npm run dev" },
]);
const devEntry = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
expect(devEntry.customScriptField).toEqual({ nested: true });
});
it("preserves all scripts on round-trip, including ones never edited in this session", () => {
const base = {
scripts: {
dev: { type: "long-running", command: "npm run dev", port: 3000, customDevField: "keep" },
build: { command: ["npm", "run", "build"], customBuildField: { nested: 1 } },
lint: { command: "npm run lint", type: "task" },
},
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// Edit only "dev". Leave "build" and "lint" untouched.
const devRow = draft.scripts.find((row) => row.name === "dev");
if (!devRow) throw new Error("expected dev row in draft");
devRow.commandText = "npm run dev -- --watch";
const next = applyDraftToConfig({ draft, base });
const scripts = next.scripts ?? {};
expect(Object.keys(scripts).sort()).toEqual(["build", "dev", "lint"]);
const devEntry = scripts.dev as unknown as Record<string, unknown>;
expect(devEntry.command).toBe("npm run dev -- --watch");
expect(devEntry.type).toBe("long-running");
expect(devEntry.port).toBe(3000);
expect(devEntry.customDevField).toBe("keep");
const buildEntry = scripts.build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm", "run", "build"]);
expect(buildEntry.customBuildField).toEqual({ nested: 1 });
const lintEntry = scripts.lint as unknown as Record<string, unknown>;
expect(lintEntry.command).toBe("npm run lint");
expect(lintEntry.type).toBe("task");
});
it("normalizes script command text into the original command kind", () => {
const base = {
scripts: {
build: { command: ["npm", "run", "build"] },
},
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const buildRow = draft.scripts[0];
buildRow.commandText = "npm run build";
const next = applyDraftToConfig({ draft, base });
const buildEntry = (next.scripts ?? {}).build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm run build"]);
});
it("parses script port as a number when numeric and writes string for non-numeric input", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.scripts = [
{
id: "row-1",
name: "dev",
commandText: "npm run dev",
commandOriginalKind: "missing",
type: "long-running",
portText: "3000",
rawEntry: {},
},
{
id: "row-2",
name: "tunnel",
commandText: "ngrok",
commandOriginalKind: "missing",
type: "long-running",
portText: "auto",
rawEntry: {},
},
];
const next = applyDraftToConfig({ draft, base });
const dev = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
const tunnel = (next.scripts ?? {}).tunnel as unknown as Record<string, unknown>;
expect(dev.port).toBe(3000);
expect(tunnel.port).toBe("auto");
});
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {
const base = {
scripts: {
dev: { command: "npm run dev" },
build: { command: "npm run build" },
},
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// remove build, add a row with empty name.
draft.scripts = draft.scripts
.filter((row) => row.name !== "build")
.concat({
id: "row-empty",
name: " ",
commandText: "echo hi",
commandOriginalKind: "missing",
type: "",
portText: "",
rawEntry: {},
});
const next = applyDraftToConfig({ draft, base });
const scripts = next.scripts ?? {};
expect(Object.keys(scripts)).toEqual(["dev"]);
});
});
@@ -0,0 +1,187 @@
import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@server/shared/messages";
export type LifecycleOriginalKind = "string" | "array" | "missing";
export interface ProjectScriptDraft {
id: string;
name: string;
commandText: string;
commandOriginalKind: LifecycleOriginalKind;
type: string;
portText: string;
rawEntry: PaseoScriptEntryRaw;
}
export interface ProjectConfigDraft {
setupText: string;
setupOriginalKind: LifecycleOriginalKind;
teardownText: string;
teardownOriginalKind: LifecycleOriginalKind;
scripts: ProjectScriptDraft[];
}
interface LifecycleProjection {
text: string;
kind: LifecycleOriginalKind;
}
function projectLifecycle(value: unknown): LifecycleProjection {
if (typeof value === "string") {
return { text: value, kind: "string" };
}
if (Array.isArray(value)) {
const lines = value.filter((entry): entry is string => typeof entry === "string");
return { text: lines.join("\n"), kind: "array" };
}
return { text: "", kind: "missing" };
}
function lifecycleFromText(
text: string,
kind: LifecycleOriginalKind,
): string | string[] | undefined {
const lines = text.split("\n").filter((line) => line.trim().length > 0);
if (lines.length === 0) {
return undefined;
}
if (kind === "string") {
return lines.join("\n");
}
if (kind === "array") {
return lines;
}
return lines.length === 1 ? lines[0] : lines;
}
function projectScriptType(value: unknown): string {
return typeof value === "string" ? value : "";
}
function projectScriptPort(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
if (typeof value === "string") {
return value;
}
return "";
}
function parseScriptPort(value: string): number | string | undefined {
const trimmed = value.trim();
if (trimmed.length === 0) {
return undefined;
}
if (/^[0-9]+$/.test(trimmed)) {
const parsed = Number(trimmed);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return trimmed;
}
let scriptDraftIdCounter = 0;
function nextScriptDraftId(): string {
scriptDraftIdCounter += 1;
return `script-draft-${scriptDraftIdCounter}`;
}
export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft {
const worktree = config?.worktree ?? {};
const setup = projectLifecycle(worktree.setup);
const teardown = projectLifecycle(worktree.teardown);
const scripts: ProjectScriptDraft[] = [];
const scriptsRecord = config?.scripts ?? {};
for (const [name, entry] of Object.entries(scriptsRecord)) {
const command = projectLifecycle(entry.command);
scripts.push({
id: nextScriptDraftId(),
name,
commandText: command.text,
commandOriginalKind: command.kind,
type: projectScriptType(entry.type),
portText: projectScriptPort(entry.port),
rawEntry: entry,
});
}
return {
setupText: setup.text,
setupOriginalKind: setup.kind,
teardownText: teardown.text,
teardownOriginalKind: teardown.kind,
scripts,
};
}
interface ApplyDraftInput {
draft: ProjectConfigDraft;
base: PaseoConfigRaw | null | undefined;
}
export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
const baseConfig = input.base ?? {};
const baseWorktree = baseConfig.worktree ?? {};
const nextWorktree: Record<string, unknown> = { ...baseWorktree };
const nextSetup = lifecycleFromText(input.draft.setupText, input.draft.setupOriginalKind);
if (nextSetup === undefined) {
delete nextWorktree.setup;
} else {
nextWorktree.setup = nextSetup;
}
const nextTeardown = lifecycleFromText(
input.draft.teardownText,
input.draft.teardownOriginalKind,
);
if (nextTeardown === undefined) {
delete nextWorktree.teardown;
} else {
nextWorktree.teardown = nextTeardown;
}
const nextScripts: Record<string, PaseoScriptEntryRaw> = {};
for (const row of input.draft.scripts) {
const trimmedName = row.name.trim();
if (trimmedName.length === 0) {
continue;
}
const baseEntry = row.rawEntry;
const nextEntry: Record<string, unknown> = { ...baseEntry };
const nextCommand = lifecycleFromText(row.commandText, row.commandOriginalKind);
if (nextCommand === undefined) {
delete nextEntry.command;
} else {
nextEntry.command = nextCommand;
}
const trimmedType = row.type.trim();
if (trimmedType.length === 0) {
delete nextEntry.type;
} else {
nextEntry.type = trimmedType;
}
const nextPort = parseScriptPort(row.portText);
if (nextPort === undefined) {
delete nextEntry.port;
} else {
nextEntry.port = nextPort;
}
nextScripts[trimmedName] = nextEntry as PaseoScriptEntryRaw;
}
const result: Record<string, unknown> = { ...baseConfig };
if (Object.keys(nextWorktree).length === 0) {
delete result.worktree;
} else {
result.worktree = nextWorktree;
}
if (Object.keys(nextScripts).length === 0) {
delete result.scripts;
} else {
result.scripts = nextScripts;
}
return result as PaseoConfigRaw;
}
+465
View File
@@ -0,0 +1,465 @@
import { describe, expect, it } from "vitest";
import type { ProjectPlacementPayload } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects } from "./projects";
function placement(input: {
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
return {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
};
}
function workspace(input: {
id: string;
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectName?: string;
remoteUrl?: string | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
status: "done",
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: input.project,
};
}
describe("buildProjects", () => {
it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-a",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-a",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-b",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-b",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app/feature",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("remote:github.com/acme/app");
expect(summary?.projectName).toBe("acme/app");
expect(summary?.hostCount).toBe(2);
expect(summary?.onlineHostCount).toBe(2);
expect(summary?.totalWorkspaceCount).toBe(5);
expect(summary?.githubUrl).toBe("https://github.com/acme/app");
expect(summary?.hosts).toHaveLength(2);
const local = summary?.hosts.find((host) => host.serverId === "local");
const laptop = summary?.hosts.find((host) => host.serverId === "laptop");
expect(local?.workspaceCount).toBe(3);
expect(laptop?.workspaceCount).toBe(2);
expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]);
expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]);
});
it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: Array.from({ length: 5 }, (_, index) =>
workspace({
id: `ws-${index}`,
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: `/repo/app/ws-${index}`,
remoteUrl: "https://github.com/acme/app.git",
}),
}),
),
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5);
expect(result.projects[0]?.totalWorkspaceCount).toBe(5);
expect(result.projects[0]?.hostCount).toBe(1);
});
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/worktrees/app/main",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/worktrees/app/main",
remoteUrl: "https://github.com/acme/app.git",
mainRepoRoot: "/repo/app",
}),
}),
],
},
{
serverId: "legacy",
serverName: "Legacy",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
}),
],
},
],
});
const acme = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
});
it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
const github = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const local = result.projects.find((project) => project.projectKey === "/repo/local");
expect(github?.githubUrl).toBe("https://github.com/acme/app");
expect(local?.githubUrl).toBeUndefined();
});
it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => {
const result = buildProjects({
hosts: [
{
serverId: "online",
serverName: "Online",
isOnline: true,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
{
serverId: "offline",
serverName: "Offline",
isOnline: false,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hostCount).toBe(2);
expect(result.projects[0]?.onlineHostCount).toBe(1);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
true,
);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
false,
);
});
it("does not merge fallback repo-root-keyed projects with different roots", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "one",
repoRoot: "/repo/one",
project: placement({
projectKey: "/repo/one",
projectName: "one",
cwd: "/repo/one",
remoteUrl: null,
}),
}),
workspace({
id: "two",
repoRoot: "/repo/two",
project: placement({
projectKey: "/repo/two",
projectName: "two",
cwd: "/repo/two",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"/repo/one",
"/repo/two",
]);
});
it("filters non-GitHub remote projects while keeping GitHub and local projects", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/github",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/github",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
expect(result.projects.map((project) => project.projectKey)).toEqual([
"remote:github.com/acme/app",
"/repo/local",
]);
});
it("produces the unsupported empty-state signal when only non-GitHub remote projects are present", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toEqual([]);
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
});
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
const result = buildProjects({
hosts: [
{
serverId: "old-daemon",
serverName: "Old daemon",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
remoteUrl: "https://gitlab.com/acme/legacy.git",
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("legacy-project");
expect(summary?.projectName).toBe("Legacy");
expect(summary?.githubUrl).toBeUndefined();
expect(summary?.hosts).toHaveLength(1);
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
expect(result.hiddenUnsupportedRemoteCount).toBe(0);
});
});
+193
View File
@@ -0,0 +1,193 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
export interface WorkspaceSummary {
id: string;
name: string;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
status: WorkspaceDescriptor["status"];
currentBranch: string | null;
}
export interface ProjectHostEntry {
serverId: string;
serverName: string;
isOnline: boolean;
repoRoot: string;
workspaceCount: number;
workspaces: WorkspaceSummary[];
gitRuntime?: WorkspaceDescriptor["gitRuntime"];
githubRuntime?: WorkspaceDescriptor["githubRuntime"];
}
export interface ProjectSummary {
projectKey: string;
projectName: string;
hosts: ProjectHostEntry[];
totalWorkspaceCount: number;
hostCount: number;
onlineHostCount: number;
githubUrl?: string;
hiddenUnsupportedRemoteCount: number;
}
export interface ProjectHost {
serverId: string;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
}
export interface BuildProjectsInput {
hosts: ProjectHost[];
}
export interface BuildProjectsResult {
projects: ProjectSummary[];
hiddenUnsupportedRemoteCount: number;
}
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
interface HostGroup {
serverId: string;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
}
interface ProjectGroup {
projectKey: string;
projectName: string;
hostsByServerId: Map<string, HostGroup>;
}
function isSupportedProjectKey(projectKey: string): boolean {
return !projectKey.startsWith("remote:") || projectKey.startsWith("remote:github.com/");
}
function deriveGithubUrl(projectKey: string): string | undefined {
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
if (!match) {
return undefined;
}
return `https://github.com/${match[1]}/${match[2]}`;
}
function resolveHostRepoRoot(workspaces: WorkspaceDescriptor[]): string {
for (const workspace of workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) {
return mainRepoRoot;
}
}
return workspaces[0]?.projectRootPath ?? "";
}
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
return {
id: workspace.id,
name: workspace.name,
workspaceKind: workspace.workspaceKind,
status: workspace.status,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
};
}
function toHostEntry(group: HostGroup): ProjectHostEntry {
const repoRoot = resolveHostRepoRoot(group.workspaces);
const canonical =
group.workspaces.find((workspace) => workspace.projectRootPath === repoRoot) ??
group.workspaces[0];
return {
serverId: group.serverId,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
workspaceCount: group.workspaces.length,
workspaces: group.workspaces.map(toWorkspaceSummary),
gitRuntime: canonical?.gitRuntime,
githubRuntime: canonical?.githubRuntime,
};
}
function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number {
const name = left.serverName.localeCompare(right.serverName);
if (name !== 0) {
return name;
}
return left.serverId.localeCompare(right.serverId);
}
function toProjectSummary(input: {
draft: ProjectGroup;
hiddenUnsupportedRemoteCount: number;
}): ProjectSummary {
const hosts = Array.from(input.draft.hostsByServerId.values())
.map(toHostEntry)
.sort(compareHosts);
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
return {
projectKey: input.draft.projectKey,
projectName: input.draft.projectName,
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: deriveGithubUrl(input.draft.projectKey),
hiddenUnsupportedRemoteCount: input.hiddenUnsupportedRemoteCount,
};
}
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
let hiddenUnsupportedRemoteCount = 0;
for (const host of input.hosts) {
for (const workspace of host.workspaces) {
const projectKey = workspace.projectId;
if (!isSupportedProjectKey(projectKey)) {
hiddenUnsupportedRemoteCount += 1;
continue;
}
let group = groups.get(projectKey);
if (!group) {
group = {
projectKey,
projectName: workspace.projectDisplayName,
hostsByServerId: new Map(),
};
groups.set(projectKey, group);
}
let hostGroup = group.hostsByServerId.get(host.serverId);
if (!hostGroup) {
hostGroup = {
serverId: host.serverId,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
};
group.hostsByServerId.set(host.serverId, hostGroup);
}
hostGroup.workspaces.push(workspace);
}
}
const projects = Array.from(groups.values()).map((draft) =>
toProjectSummary({ draft, hiddenUnsupportedRemoteCount }),
);
projects.sort((left, right) => {
const name = left.projectName.localeCompare(right.projectName);
if (name !== 0) {
return name;
}
return left.projectKey.localeCompare(right.projectKey);
});
return {
projects,
hiddenUnsupportedRemoteCount,
};
}
@@ -13,6 +13,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
workspaceKey: "srv:ws-root",
serverId: "srv",
workspaceId: "ws-root",
projectKey: "project-1",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
@@ -11,11 +11,13 @@ function workspace(input: {
workspaceId: string;
workspaceDirectory: string;
name: string;
projectKey?: string;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
projectKey: input.projectKey ?? "project-default",
workspaceDirectory: input.workspaceDirectory,
projectKind: "git",
workspaceKind: "checkout",
-1
View File
@@ -30,7 +30,6 @@ export function addRunOptions(cmd: Command): Command {
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
"claude",
)
.option(
"--model <model>",
@@ -27,6 +27,7 @@ export function createDaemonCommand(): Command {
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option("--kill-timeout <seconds>", "Wait after SIGKILL before failing (default: 3)")
.action(withOutput(runStopCommand));
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
@@ -43,6 +43,7 @@ export interface DetachedStartResult {
export interface StopLocalDaemonOptions {
home?: string;
timeoutMs?: number;
killTimeoutMs?: number;
force?: boolean;
}
@@ -64,11 +65,11 @@ type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & P
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const KILL_TIMEOUT_MS = 3000;
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const require = createRequire(import.meta.url);
@@ -494,6 +495,7 @@ export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {},
): Promise<StopLocalDaemonResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
if (!state.pidInfo || !state.running) {
@@ -530,7 +532,7 @@ export async function stopLocalDaemon(
if (!stopped && options.force) {
forced = true;
signalProcessGroupSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
stopped = await waitForPidExit(pid, killTimeoutMs);
}
if (!stopped) {
+16 -7
View File
@@ -1,5 +1,9 @@
import type { Command } from "commander";
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from "./local-daemon.js";
import {
stopLocalDaemon,
DEFAULT_STOP_TIMEOUT_MS,
DEFAULT_KILL_TIMEOUT_MS,
} from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
@@ -30,17 +34,17 @@ const stopResultSchema: OutputSchema<StopResult> = {
export type StopCommandResult = SingleResult<StopResult>;
function parseTimeoutMs(raw: unknown): number {
function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number {
if (typeof raw !== "string" || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS;
return fallbackMs;
}
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: `Invalid timeout value: ${raw}`,
details: "Timeout must be a positive number of seconds",
message: `Invalid ${label} value: ${raw}`,
details: `${label} must be a positive number of seconds`,
};
throw error;
}
@@ -54,10 +58,15 @@ export async function runStopCommand(
): Promise<StopCommandResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const force = options.force === true;
const timeoutMs = parseTimeoutMs(options.timeout);
const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout");
const killTimeoutMs = parseSecondsOption(
options.killTimeout,
DEFAULT_KILL_TIMEOUT_MS,
"kill-timeout",
);
try {
const result = await stopLocalDaemon({ home, force, timeoutMs });
const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs });
return {
type: "single",
data: {
+6 -3
View File
@@ -1,13 +1,13 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server";
import { AGENT_PROVIDER_DEFINITIONS, type ProviderSnapshotEntry } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
/** Provider list item for display */
export interface ProviderListItem {
provider: string;
provider: ProviderSnapshotEntry["provider"];
label: string;
status: string;
enabled: "Enabled" | "Disabled";
defaultMode: string;
modes: string;
}
@@ -17,6 +17,7 @@ const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
enabled: "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
@@ -41,6 +42,7 @@ export const providerLsSchema: OutputSchema<ProviderListItem> = {
return undefined;
},
},
{ header: "ENABLED", field: "enabled", width: 10 },
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
{ header: "MODES", field: "modes", width: 30 },
],
@@ -74,6 +76,7 @@ export async function runLsCommand(
provider: entry.provider,
label: entry.label ?? entry.provider,
status: entry.status === "ready" ? "available" : entry.status,
enabled: entry.enabled === false ? "Disabled" : "Enabled",
defaultMode: entry.defaultModeId ?? "default",
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
})),
+17 -16
View File
@@ -86,20 +86,20 @@ export function formatDurationMs(durationMs: number): string {
function resolveScheduleTarget(args: {
targetValue: string | undefined;
hasExplicitProviderSelection: boolean;
newAgentTarget: ScheduleTarget;
createNewAgentTarget: () => ScheduleTarget;
}): ScheduleTarget {
const { targetValue, hasExplicitProviderSelection, newAgentTarget } = args;
const { targetValue, hasExplicitProviderSelection, createNewAgentTarget } = args;
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (!targetValue) {
if (currentAgentId && !hasExplicitProviderSelection) {
return { type: "self", agentId: currentAgentId };
}
return newAgentTarget;
return createNewAgentTarget();
}
if (targetValue === "new-agent") {
return newAgentTarget;
return createNewAgentTarget();
}
if (hasExplicitProviderSelection) {
@@ -155,22 +155,23 @@ export function parseScheduleCreateInput(options: {
const targetValue = options.target?.trim();
const hasExplicitProviderSelection = options.provider !== undefined;
const resolvedProviderModel = resolveProviderAndModel({
provider: options.provider,
defaultProvider: "claude",
});
const newAgentTarget: ScheduleTarget = {
type: "new-agent",
config: {
provider: resolvedProviderModel.provider,
cwd: process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
},
const createNewAgentTarget = (): ScheduleTarget => {
const resolvedProviderModel = resolveProviderAndModel({
provider: options.provider,
});
return {
type: "new-agent",
config: {
provider: resolvedProviderModel.provider,
cwd: process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
},
};
};
const target = resolveScheduleTarget({
targetValue,
hasExplicitProviderSelection,
newAgentTarget,
createNewAgentTarget,
});
const maxRuns =
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { resolveProviderAndModel } from "./provider-model.js";
describe("resolveProviderAndModel", () => {
test("requires an explicit provider when no default is supplied", () => {
expect(() => resolveProviderAndModel({})).toThrow(
expect.objectContaining({
code: "MISSING_PROVIDER",
message: "Provider is required",
}),
);
});
test("uses an explicit default provider when supplied by a scoped caller", () => {
expect(resolveProviderAndModel({ defaultProvider: "claude" })).toEqual({
provider: "claude",
model: undefined,
});
});
test("parses provider/model shorthand", () => {
expect(resolveProviderAndModel({ provider: "codex/gpt-5.4" })).toEqual({
provider: "codex",
model: "gpt-5.4",
});
});
test("rejects conflicting model values", () => {
expect(() =>
resolveProviderAndModel({ provider: "codex/gpt-5.4", model: "gpt-5.4-mini" }),
).toThrow(
expect.objectContaining({
code: "CONFLICTING_MODEL_OPTIONS",
}),
);
});
});
+11 -1
View File
@@ -14,9 +14,19 @@ export interface ResolvedProviderModel {
export function resolveProviderAndModel(
options: ResolveProviderAndModelOptions,
): ResolvedProviderModel {
const providerInput = options.provider?.trim() || options.defaultProvider || "claude";
const providerInput = options.provider?.trim() || options.defaultProvider;
const modelInput = options.model?.trim();
if (!providerInput) {
const error: CommandError = {
code: "MISSING_PROVIDER",
message: "Provider is required",
details:
"Pass --provider <provider> or --provider <provider>/<model>. Use `paseo provider ls` to see providers and `paseo provider models <provider>` to see models.",
};
throw error;
}
if (options.model !== undefined && !modelInput) {
const error: CommandError = {
code: "INVALID_MODEL",
+91 -15
View File
@@ -20,7 +20,14 @@
*/
import assert from "node:assert";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
createE2ETestContext,
createTempDirs,
runPaseoCli,
startTestDaemon,
} from "./helpers/test-daemon.ts";
console.log("=== Provider Commands ===\n");
@@ -30,6 +37,13 @@ interface ProviderModel {
description?: string;
}
interface ProviderListRow {
provider: string;
label: string;
status: string;
enabled: string;
}
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-opus-4-7[1m]",
@@ -140,6 +154,8 @@ try {
assert(result.stdout.includes("claude"), "output should include claude");
assert(result.stdout.includes("codex"), "output should include codex");
assert(result.stdout.includes("opencode"), "output should include opencode");
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
assert(
result.stdout.includes("available") ||
result.stdout.includes("loading") ||
@@ -169,12 +185,72 @@ try {
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
assert(
data.every((p: ProviderListRow) => p.enabled === "Enabled"),
"enabled providers should report Enabled",
);
console.log("✓ provider ls --json outputs valid JSON\n");
}
// Test 4: provider ls --quiet outputs provider names only
// Test 4: provider ls includes disabled providers
{
console.log("Test 4: provider ls --quiet outputs provider names only");
console.log("Test 4: provider ls includes disabled providers");
const { paseoHome, workDir } = await createTempDirs();
await writeFile(
join(paseoHome, "config.json"),
JSON.stringify(
{
version: 1,
agents: {
providers: {
claude: {
enabled: false,
},
},
},
},
null,
2,
) + "\n",
);
const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
try {
const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
const claude = data.find((p) => p.provider === "claude");
assert(claude, "disabled claude provider should stay in provider ls");
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
const opencode = data.find((p) => p.provider === "opencode");
assert(opencode, "enabled opencode provider should stay in provider ls");
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
assert.notStrictEqual(
modelsResult.exitCode,
0,
"provider models should fail for disabled providers",
);
const output = modelsResult.stdout + modelsResult.stderr;
assert(
output.includes("Provider claude is not available"),
"provider models should surface the daemon disabled error",
);
assert(
!output.includes("claude-sonnet"),
"provider models should not print fallback models for disabled providers",
);
} finally {
await disabledCtx.stop();
}
console.log("✓ provider ls includes disabled providers\n");
}
// Test 5: provider ls --quiet outputs provider names only
{
console.log("Test 5: provider ls --quiet outputs provider names only");
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n");
@@ -185,17 +261,17 @@ try {
console.log("✓ provider ls --quiet outputs provider names only\n");
}
// Test 5: provider models claude lists canonical model aliases
// Test 6: provider models claude lists canonical model aliases
{
console.log("Test 5: provider models claude lists canonical model aliases");
console.log("Test 6: provider models claude lists canonical model aliases");
const data = await runProviderModelsJson("claude");
assertClaudeModels(data);
console.log("✓ provider models claude lists canonical model aliases\n");
}
// Test 6: provider models codex includes concrete codex model IDs
// Test 7: provider models codex includes concrete codex model IDs
{
console.log("Test 6: provider models codex includes concrete codex model IDs");
console.log("Test 7: provider models codex includes concrete codex model IDs");
const data = await runProviderModelsJson("codex");
assert(data.length >= 1, "codex model list should not be empty");
const ids = data.map((m) => m.id);
@@ -215,9 +291,9 @@ try {
console.log("✓ provider models codex includes concrete codex model IDs\n");
}
// Test 7: provider models opencode returns namespaced model IDs
// Test 8: provider models opencode returns namespaced model IDs
{
console.log("Test 7: provider models opencode returns namespaced model IDs");
console.log("Test 8: provider models opencode returns namespaced model IDs");
const data = await runProviderModelsJson("opencode");
assert(data.length >= 1, "opencode model list should not be empty");
const ids = data.map((m) => m.id);
@@ -236,9 +312,9 @@ try {
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
// Test 8: provider models unknown fails with error
// Test 9: provider models unknown fails with error
{
console.log("Test 8: provider models unknown fails with error");
console.log("Test 9: provider models unknown fails with error");
const result = await ctx.paseo(["provider", "models", "unknown"]);
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
const output = result.stdout + result.stderr;
@@ -249,9 +325,9 @@ try {
console.log("✓ provider models unknown fails with error\n");
}
// Test 9: provider models --json outputs valid JSON
// Test 10: provider models --json outputs valid JSON
{
console.log("Test 9: provider models --json outputs valid JSON");
console.log("Test 10: provider models --json outputs valid JSON");
const data = await runProviderModelsJson("claude");
assert(Array.isArray(data), "output should be an array");
assert(
@@ -264,9 +340,9 @@ try {
console.log("✓ provider models --json outputs valid JSON\n");
}
// Test 10: provider models --quiet outputs model IDs only
// Test 11: provider models --quiet outputs model IDs only
{
console.log("Test 10: provider models --quiet outputs model IDs only");
console.log("Test 11: provider models --quiet outputs model IDs only");
assert(
claudeModelIdsFromJson.length > 0,
"claude model IDs should be captured from --json output",
+1
View File
@@ -9,6 +9,7 @@ files:
- dist/**/*
asarUnpack:
- dist/daemon/node-entrypoint-runner.js
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*
extraResources:
- from: ../app/dist
to: app-dist
+68 -30
View File
@@ -33,6 +33,11 @@ import {
runCliJsonCommand,
runCliTextCommand,
} from "./runtime-paths.js";
import {
createDesktopSettingsCommandHandlers,
type DesktopCommandHandler,
} from "../settings/desktop-settings-commands.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const PID_POLL_INTERVAL_MS = 100;
@@ -44,7 +49,7 @@ const DETACHED_STARTUP_GRACE_MS = 1200;
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
interface DesktopDaemonStatus {
export interface DesktopDaemonStatus {
serverId: string;
status: DesktopDaemonState;
listen: string | null;
@@ -67,10 +72,16 @@ interface DesktopPairingOffer {
qr: string | null;
}
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
function parseReleaseChannel(args: Record<string, unknown> | undefined): AppReleaseChannel {
return args?.releaseChannel === "beta" ? "beta" : "stable";
function parseReleaseChannel(
args: Record<string, unknown> | undefined,
): AppReleaseChannel | undefined {
if (args?.releaseChannel === "beta") {
return "beta";
}
if (args?.releaseChannel === "stable") {
return "stable";
}
return undefined;
}
// ---------------------------------------------------------------------------
@@ -85,6 +96,31 @@ function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
try {
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
if (lock.desktopManaged !== true) return false;
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
return isProcessRunning(lock.pid);
} catch {
return false;
}
}
export async function stopDesktopDaemonViaCli(): Promise<void> {
await runCliJsonCommand([
"daemon",
"stop",
"--json",
"--timeout",
"5",
"--force",
"--kill-timeout",
"5",
]);
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -195,7 +231,7 @@ function resolveDesktopAppVersion(): string {
// Daemon lifecycle
// ---------------------------------------------------------------------------
async function resolveStatus(): Promise<DesktopDaemonStatus> {
export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
const home = getPaseoHome();
try {
@@ -265,8 +301,8 @@ function buildStartupFailureError(
async function pollForRunningDaemon(): Promise<DesktopDaemonStatus> {
async function poll(attempt: number): Promise<DesktopDaemonStatus> {
if (attempt >= STARTUP_POLL_MAX_ATTEMPTS) return resolveStatus();
const status = await resolveStatus();
if (attempt >= STARTUP_POLL_MAX_ATTEMPTS) return resolveDesktopDaemonStatus();
const status = await resolveDesktopDaemonStatus();
if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) {
logDesktopDaemonLifecycle("polling daemon status after detached start", {
attempt: attempt + 1,
@@ -284,7 +320,7 @@ async function pollForRunningDaemon(): Promise<DesktopDaemonStatus> {
}
async function startDaemon(): Promise<DesktopDaemonStatus> {
const current = await resolveStatus();
const current = await resolveDesktopDaemonStatus();
logDesktopDaemonLifecycle("initial status check before start", {
status: current.status,
pid: current.pid,
@@ -299,7 +335,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
appVersion: normalizeVersion(resolveDesktopAppVersion()),
daemonVersion: normalizeVersion(current.version),
});
await stopDaemon();
await stopDesktopDaemon();
} else {
return current;
}
@@ -389,8 +425,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
return pollForRunningDaemon();
}
async function stopDaemon(): Promise<DesktopDaemonStatus> {
const status = await resolveStatus();
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
const status = await resolveDesktopDaemonStatus();
if (status.status !== "running" || !status.pid) return status;
const pid = status.pid;
@@ -406,11 +442,11 @@ async function stopDaemon(): Promise<DesktopDaemonStatus> {
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
}
return await resolveStatus();
return await resolveDesktopDaemonStatus();
}
async function restartDaemon(): Promise<DesktopDaemonStatus> {
await stopDaemon();
await stopDesktopDaemon();
return startDaemon();
}
@@ -427,7 +463,7 @@ async function getCliDaemonStatus(): Promise<string> {
}
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
const status = await resolveStatus();
const status = await resolveDesktopDaemonStatus();
if (status.status !== "running") {
return {
relayEnabled: false,
@@ -457,7 +493,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> {
const status = await resolveStatus();
const status = await resolveDesktopDaemonStatus();
if (status.status !== "running") {
return { version: null, error: "Daemon is not running." };
}
@@ -467,12 +503,10 @@ async function getLocalDaemonVersion(): Promise<{ version: string | null; error:
};
}
function resolveCurrentUpdateVersion(): string {
return resolveDesktopAppVersion();
}
function getSystemIdleTimeMs(): number {
return powerMonitor.getSystemIdleTime() * 1000;
async function resolveRequestedReleaseChannel(
args: Record<string, unknown> | undefined,
): Promise<AppReleaseChannel> {
return parseReleaseChannel(args) ?? (await getDesktopSettingsStore().get()).releaseChannel;
}
// ---------------------------------------------------------------------------
@@ -481,13 +515,14 @@ function getSystemIdleTimeMs(): number {
export function createDaemonCommandHandlers(): Record<string, DesktopCommandHandler> {
return {
desktop_daemon_status: () => resolveStatus(),
...createDesktopSettingsCommandHandlers({ settingsStore: getDesktopSettingsStore() }),
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
start_desktop_daemon: () => startDaemon(),
stop_desktop_daemon: () => stopDaemon(),
stop_desktop_daemon: () => stopDesktopDaemon(),
restart_desktop_daemon: () => restartDaemon(),
desktop_daemon_logs: () => getDaemonLogs(),
desktop_daemon_pairing: () => getDaemonPairing(),
desktop_get_system_idle_time: () => getSystemIdleTimeMs(),
desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000,
cli_daemon_status: () => getCliDaemonStatus(),
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
@@ -511,15 +546,18 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
if (sessionId) closeLocalTransportSession(sessionId);
},
check_app_update: async (args) => {
const currentVersion = await resolveCurrentUpdateVersion();
return checkForAppUpdate({ currentVersion, releaseChannel: parseReleaseChannel(args) });
const currentVersion = resolveDesktopAppVersion();
return checkForAppUpdate({
currentVersion,
releaseChannel: await resolveRequestedReleaseChannel(args),
});
},
install_app_update: async (args) => {
const currentVersion = await resolveCurrentUpdateVersion();
const currentVersion = resolveDesktopAppVersion();
return downloadAndInstallUpdate(
{ currentVersion, releaseChannel: parseReleaseChannel(args) },
{ currentVersion, releaseChannel: await resolveRequestedReleaseChannel(args) },
async () => {
await stopDaemon();
await stopDesktopDaemon();
},
);
},
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
describe("desktop packaging", () => {
it("unpacks server zsh shell integration files for external shells", () => {
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
expect(config).toContain(
"node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*",
);
expect(config).not.toContain(
"node_modules/@getpaseo/server/dist/src/terminal/shell-integration/**/*",
);
});
});
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import {
createBeforeQuitHandler,
shouldStopDesktopManagedDaemonOnQuit,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./quit-lifecycle";
const SETTINGS_KEEP_RUNNING = DEFAULT_DESKTOP_SETTINGS;
const SETTINGS_STOP_ON_QUIT = {
...DEFAULT_DESKTOP_SETTINGS,
daemon: {
...DEFAULT_DESKTOP_SETTINGS.daemon,
keepRunningAfterQuit: false,
},
};
describe("quit-lifecycle", () => {
it("only stops when keepRunningAfterQuit is explicitly disabled", () => {
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
});
it("short-circuits without inspecting the daemon when keep-running is on", async () => {
const isDesktopManagedDaemonRunning = vi.fn(() => true);
const stopDaemon = vi.fn(async () => undefined);
const showShutdownFeedback = vi.fn();
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: { get: async () => SETTINGS_KEEP_RUNNING },
isDesktopManagedDaemonRunning,
stopDaemon,
showShutdownFeedback,
});
expect(stopped).toBe(false);
expect(isDesktopManagedDaemonRunning).not.toHaveBeenCalled();
expect(stopDaemon).not.toHaveBeenCalled();
expect(showShutdownFeedback).not.toHaveBeenCalled();
});
it("does not stop a manually started daemon on quit", async () => {
const stopDaemon = vi.fn(async () => undefined);
const showShutdownFeedback = vi.fn();
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
isDesktopManagedDaemonRunning: () => false,
stopDaemon,
showShutdownFeedback,
});
expect(stopped).toBe(false);
expect(stopDaemon).not.toHaveBeenCalled();
expect(showShutdownFeedback).not.toHaveBeenCalled();
});
it("shows feedback then stops a desktop-managed daemon", async () => {
const stopDaemon = vi.fn(async () => undefined);
const showShutdownFeedback = vi.fn();
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
isDesktopManagedDaemonRunning: () => true,
stopDaemon,
showShutdownFeedback,
});
expect(stopped).toBe(true);
expect(showShutdownFeedback).toHaveBeenCalledTimes(1);
expect(stopDaemon).toHaveBeenCalledTimes(1);
expect(showShutdownFeedback.mock.invocationCallOrder[0]).toBeLessThan(
stopDaemon.mock.invocationCallOrder[0],
);
});
it("gates quit until the stop decision completes, then lets the next quit pass through", async () => {
let resolveStopDecision: (() => void) | null = null;
const app = { quit: vi.fn() };
const closeTransportSessions = vi.fn();
const onStopError = vi.fn();
const preventDefault = vi.fn();
const secondPreventDefault = vi.fn();
const handleBeforeQuit = createBeforeQuitHandler({
app,
closeTransportSessions,
stopDesktopManagedDaemonIfNeeded: vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveStopDecision = () => resolve(false);
}),
),
onStopError,
});
handleBeforeQuit({ preventDefault });
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(closeTransportSessions).toHaveBeenCalledTimes(1);
expect(app.quit).not.toHaveBeenCalled();
expect(resolveStopDecision).not.toBeNull();
resolveStopDecision?.();
await Promise.resolve();
await Promise.resolve();
expect(app.quit).toHaveBeenCalledTimes(1);
expect(onStopError).not.toHaveBeenCalled();
handleBeforeQuit({ preventDefault: secondPreventDefault });
expect(secondPreventDefault).not.toHaveBeenCalled();
expect(closeTransportSessions).toHaveBeenCalledTimes(2);
expect(app.quit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,82 @@
import type { DesktopSettingsStore } from "../settings/desktop-settings.js";
interface QuitLifecycleSettings {
daemon: {
keepRunningAfterQuit: boolean;
};
}
interface BeforeQuitEvent {
preventDefault(): void;
}
interface BeforeQuitApp {
quit(): void;
}
export interface StopOnQuitDeps {
settingsStore: Pick<DesktopSettingsStore, "get">;
isDesktopManagedDaemonRunning: () => boolean;
stopDaemon: () => Promise<unknown>;
showShutdownFeedback: () => void;
}
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
return settings.daemon.keepRunningAfterQuit === false;
}
export async function stopDesktopManagedDaemonOnQuitIfNeeded(
deps: StopOnQuitDeps,
): Promise<boolean> {
const settings = await deps.settingsStore.get();
if (!shouldStopDesktopManagedDaemonOnQuit(settings)) {
return false;
}
if (!deps.isDesktopManagedDaemonRunning()) {
return false;
}
deps.showShutdownFeedback();
await deps.stopDaemon();
return true;
}
export function createBeforeQuitHandler({
app,
closeTransportSessions,
stopDesktopManagedDaemonIfNeeded,
onStopError,
}: {
app: BeforeQuitApp;
closeTransportSessions: () => void;
stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>;
onStopError: (error: unknown) => void;
}): (event: BeforeQuitEvent) => void {
let allowingQuitToContinue = false;
let quittingInProgress = false;
return (event) => {
closeTransportSessions();
if (allowingQuitToContinue) {
return;
}
event.preventDefault();
if (quittingInProgress) {
return;
}
quittingInProgress = true;
void stopDesktopManagedDaemonIfNeeded()
.catch((error) => {
onStopError(error);
})
.finally(() => {
allowingQuitToContinue = true;
quittingInProgress = false;
app.quit();
});
};
}
+42 -5
View File
@@ -9,7 +9,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, protocol } from "electron";
import { registerDaemonManager } from "./daemon/daemon-manager.js";
import {
parseCliPassthroughArgsFromArgv,
@@ -34,6 +34,15 @@ import {
import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
import {
isDesktopManagedDaemonRunningSync,
stopDesktopDaemonViaCli,
} from "./daemon/daemon-manager.js";
import {
createBeforeQuitHandler,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
@@ -43,7 +52,11 @@ app.setName("Paseo");
// In dev mode, detect git worktrees and isolate each instance so multiple
// Electron windows can run side-by-side (separate userData = separate lock).
let devWorktreeName: string | null = null;
if (!app.isPackaged) {
const forcedUserDataDir = process.env.PASEO_ELECTRON_USER_DATA_DIR?.trim();
if (forcedUserDataDir) {
app.setPath("userData", forcedUserDataDir);
log.info("[dev-user-data] forced userData dir:", forcedUserDataDir);
} else if (!app.isPackaged) {
try {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8",
@@ -336,9 +349,33 @@ void bootstrap().catch((error) => {
process.exit(1);
});
app.on("before-quit", () => {
closeAllTransportSessions();
});
function showDaemonShutdownDialog(): void {
void dialog.showMessageBox({
type: "info",
message: "Shutting down Paseo daemon…",
detail: "Paseo will quit once the local daemon has stopped.",
buttons: [],
noLink: true,
});
}
app.on(
"before-quit",
createBeforeQuitHandler({
app,
closeTransportSessions: closeAllTransportSessions,
stopDesktopManagedDaemonIfNeeded: () =>
stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: getDesktopSettingsStore(),
isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync,
stopDaemon: stopDesktopDaemonViaCli,
showShutdownFeedback: showDaemonShutdownDialog,
}),
onStopError: (error) => {
log.error("[desktop daemon] failed to stop managed daemon on quit", error);
},
}),
);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettingsStore } from "./desktop-settings";
import { createDesktopSettingsCommandHandlers } from "./desktop-settings-commands";
function createStoreMock(): DesktopSettingsStore {
return {
get: vi.fn(async () => DEFAULT_DESKTOP_SETTINGS),
patch: vi.fn(async () => ({
...DEFAULT_DESKTOP_SETTINGS,
releaseChannel: "beta",
})),
migrateLegacyRendererSettings: vi.fn(async () => ({
...DEFAULT_DESKTOP_SETTINGS,
releaseChannel: "beta",
daemon: {
manageBuiltInDaemon: false,
keepRunningAfterQuit: true,
},
})),
};
}
describe("desktop-settings-commands", () => {
it("exposes get and patch handlers through the desktop command bus shape", async () => {
const store = createStoreMock();
const handlers = createDesktopSettingsCommandHandlers({ settingsStore: store });
await expect(handlers.get_desktop_settings()).resolves.toEqual(DEFAULT_DESKTOP_SETTINGS);
await expect(
handlers.patch_desktop_settings({
daemon: { keepRunningAfterQuit: false },
}),
).resolves.toEqual({
...DEFAULT_DESKTOP_SETTINGS,
releaseChannel: "beta",
});
expect(store.get).toHaveBeenCalledTimes(1);
expect(store.patch).toHaveBeenCalledWith({
daemon: { keepRunningAfterQuit: false },
});
});
it("accepts legacy renderer settings migration payloads", async () => {
const store = createStoreMock();
const handlers = createDesktopSettingsCommandHandlers({ settingsStore: store });
const result = await handlers.migrate_legacy_desktop_settings({
releaseChannel: "beta",
manageBuiltInDaemon: false,
});
expect(result).toEqual({
...DEFAULT_DESKTOP_SETTINGS,
releaseChannel: "beta",
daemon: {
manageBuiltInDaemon: false,
keepRunningAfterQuit: true,
},
});
expect(store.migrateLegacyRendererSettings).toHaveBeenCalledWith({
releaseChannel: "beta",
manageBuiltInDaemon: false,
});
});
});
@@ -0,0 +1,15 @@
import type { DesktopSettingsStore } from "./desktop-settings.js";
export type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
export function createDesktopSettingsCommandHandlers({
settingsStore,
}: {
settingsStore: DesktopSettingsStore;
}): Record<string, DesktopCommandHandler> {
return {
get_desktop_settings: () => settingsStore.get(),
patch_desktop_settings: (args) => settingsStore.patch(args),
migrate_legacy_desktop_settings: (args) => settingsStore.migrateLegacyRendererSettings(args),
};
}
@@ -0,0 +1,12 @@
import { app } from "electron";
import { createDesktopSettingsStore, type DesktopSettingsStore } from "./desktop-settings.js";
let desktopSettingsStore: DesktopSettingsStore | null = null;
export function getDesktopSettingsStore(): DesktopSettingsStore {
desktopSettingsStore ??= createDesktopSettingsStore({
userDataPath: app.getPath("userData"),
});
return desktopSettingsStore;
}
@@ -0,0 +1,127 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_DESKTOP_SETTINGS,
type DesktopSettings,
createDesktopSettingsStore,
} from "./desktop-settings";
async function createTempUserDataDir(): Promise<string> {
return await mkdtemp(path.join(os.tmpdir(), "paseo-desktop-settings-"));
}
function settingsFilePath(userDataPath: string): string {
return path.join(userDataPath, "desktop-settings.json");
}
describe("desktop-settings", () => {
const directories = new Set<string>();
afterEach(async () => {
await Promise.all(
[...directories].map(async (directory) => {
await rm(directory, { recursive: true, force: true });
}),
);
directories.clear();
});
it("persists default settings for new users", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createDesktopSettingsStore({ userDataPath });
const settings = await store.get();
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
settings: DesktopSettings;
};
expect(settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
});
it("coerces invalid persisted values back to safe defaults", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
settingsFilePath(userDataPath),
JSON.stringify({
version: 1,
settings: {
releaseChannel: "nightly",
daemon: {
manageBuiltInDaemon: "sometimes",
keepRunningAfterQuit: false,
},
},
}),
);
const store = createDesktopSettingsStore({ userDataPath });
const settings = await store.get();
expect(settings).toEqual({
releaseChannel: "stable",
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: false,
},
});
});
it("patches nested settings and leaves no temp files behind", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createDesktopSettingsStore({ userDataPath });
await store.get();
const next = await store.patch({
releaseChannel: "beta",
daemon: { keepRunningAfterQuit: false },
});
const files = await readdir(userDataPath);
expect(next).toEqual({
releaseChannel: "beta",
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: false,
},
});
expect(files).toEqual(["desktop-settings.json"]);
});
it("migrates desktop-owned values from legacy renderer settings once", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createDesktopSettingsStore({ userDataPath });
await store.patch({
daemon: {
keepRunningAfterQuit: false,
},
});
const migrated = await store.migrateLegacyRendererSettings({
releaseChannel: "beta",
manageBuiltInDaemon: false,
theme: "dark",
});
const ignoredSecondMigration = await store.migrateLegacyRendererSettings({
releaseChannel: "stable",
manageBuiltInDaemon: true,
});
expect(migrated).toEqual({
releaseChannel: "beta",
daemon: {
manageBuiltInDaemon: false,
keepRunningAfterQuit: false,
},
});
expect(ignoredSecondMigration).toEqual(migrated);
});
});
@@ -0,0 +1,261 @@
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AppReleaseChannel } from "../features/auto-updater.js";
export interface DesktopSettings {
releaseChannel: AppReleaseChannel;
daemon: {
manageBuiltInDaemon: boolean;
keepRunningAfterQuit: boolean;
};
}
interface DesktopSettingsPatch {
releaseChannel?: AppReleaseChannel;
daemon?: Partial<DesktopSettings["daemon"]>;
}
interface PersistedDesktopSettingsDocument {
version: 1;
settings: DesktopSettings;
migrations: {
legacyRendererSettingsImported: boolean;
};
}
export interface DesktopSettingsStore {
get(): Promise<DesktopSettings>;
patch(patch: unknown): Promise<DesktopSettings>;
migrateLegacyRendererSettings(legacySettings: unknown): Promise<DesktopSettings>;
}
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
releaseChannel: "stable",
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: true,
},
};
const DESKTOP_SETTINGS_FILENAME = "desktop-settings.json";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function coerceReleaseChannel(value: unknown): AppReleaseChannel | null {
if (value === "beta") {
return "beta";
}
if (value === "stable") {
return "stable";
}
return null;
}
function coerceBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function buildDefaultDocument(): PersistedDesktopSettingsDocument {
return {
version: 1,
settings: DEFAULT_DESKTOP_SETTINGS,
migrations: {
legacyRendererSettingsImported: false,
},
};
}
function coerceDesktopSettings(input: unknown): DesktopSettings {
const result: DesktopSettings = {
releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel,
daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon },
};
if (!isRecord(input)) {
return result;
}
const releaseChannel = coerceReleaseChannel(input.releaseChannel);
if (releaseChannel) {
result.releaseChannel = releaseChannel;
}
if (isRecord(input.daemon)) {
const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon);
if (manageBuiltInDaemon !== null) {
result.daemon.manageBuiltInDaemon = manageBuiltInDaemon;
}
const keepRunningAfterQuit = coerceBoolean(input.daemon.keepRunningAfterQuit);
if (keepRunningAfterQuit !== null) {
result.daemon.keepRunningAfterQuit = keepRunningAfterQuit;
}
}
return result;
}
function coerceDesktopSettingsPatch(input: unknown): DesktopSettingsPatch {
if (!isRecord(input)) {
return {};
}
const patch: DesktopSettingsPatch = {};
const releaseChannel = coerceReleaseChannel(input.releaseChannel);
if (releaseChannel) {
patch.releaseChannel = releaseChannel;
}
if (isRecord(input.daemon)) {
const daemonPatch: Partial<DesktopSettings["daemon"]> = {};
const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon);
if (manageBuiltInDaemon !== null) {
daemonPatch.manageBuiltInDaemon = manageBuiltInDaemon;
}
const keepRunningAfterQuit = coerceBoolean(input.daemon.keepRunningAfterQuit);
if (keepRunningAfterQuit !== null) {
daemonPatch.keepRunningAfterQuit = keepRunningAfterQuit;
}
if (Object.keys(daemonPatch).length > 0) {
patch.daemon = daemonPatch;
}
}
return patch;
}
function pickDesktopSettingsFromLegacyRendererSettings(
legacySettings: unknown,
): DesktopSettingsPatch {
if (!isRecord(legacySettings)) {
return {};
}
const patch: DesktopSettingsPatch = {};
const releaseChannel = coerceReleaseChannel(legacySettings.releaseChannel);
if (releaseChannel) {
patch.releaseChannel = releaseChannel;
}
const manageBuiltInDaemon = coerceBoolean(legacySettings.manageBuiltInDaemon);
if (manageBuiltInDaemon !== null) {
patch.daemon = { manageBuiltInDaemon };
}
return patch;
}
function mergeDesktopSettings(
current: DesktopSettings,
patch: DesktopSettingsPatch,
): DesktopSettings {
return {
releaseChannel: patch.releaseChannel ?? current.releaseChannel,
daemon: { ...current.daemon, ...patch.daemon },
};
}
function coerceDocument(input: unknown): PersistedDesktopSettingsDocument {
if (!isRecord(input)) {
return buildDefaultDocument();
}
const settings = coerceDesktopSettings(input.settings);
const migrations = isRecord(input.migrations)
? {
legacyRendererSettingsImported: input.migrations.legacyRendererSettingsImported === true,
}
: {
legacyRendererSettingsImported: false,
};
return {
version: 1,
settings,
migrations,
};
}
export function createDesktopSettingsStore({
userDataPath,
}: {
userDataPath: string;
}): DesktopSettingsStore {
const filePath = path.join(userDataPath, DESKTOP_SETTINGS_FILENAME);
let cachedDocument: PersistedDesktopSettingsDocument | null = null;
async function persistDocument(document: PersistedDesktopSettingsDocument): Promise<void> {
await mkdir(userDataPath, { recursive: true });
const tempFilePath = `${filePath}.tmp`;
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
await rename(tempFilePath, filePath);
cachedDocument = document;
}
async function loadDocument(): Promise<PersistedDesktopSettingsDocument> {
if (cachedDocument) {
return cachedDocument;
}
try {
await access(filePath);
} catch {
const document = buildDefaultDocument();
await persistDocument(document);
return document;
}
try {
const raw = await readFile(filePath, "utf8");
const document = coerceDocument(JSON.parse(raw));
await persistDocument(document);
return document;
} catch {
const document = buildDefaultDocument();
await persistDocument(document);
return document;
}
}
return {
async get(): Promise<DesktopSettings> {
const document = await loadDocument();
return document.settings;
},
async patch(patch: unknown): Promise<DesktopSettings> {
const current = await loadDocument();
const next = mergeDesktopSettings(current.settings, coerceDesktopSettingsPatch(patch));
await persistDocument({
...current,
settings: next,
});
return next;
},
async migrateLegacyRendererSettings(legacySettings: unknown): Promise<DesktopSettings> {
const current = await loadDocument();
if (current.migrations.legacyRendererSettingsImported) {
return current.settings;
}
const next = mergeDesktopSettings(
current.settings,
pickDesktopSettingsFromLegacyRendererSettings(legacySettings),
);
await persistDocument({
...current,
settings: next,
migrations: {
...current.migrations,
legacyRendererSettingsImported: true,
},
});
return next;
},
};
}
+3 -3
View File
@@ -61,9 +61,9 @@
"@getpaseo/highlight": "0.1.63-beta.1",
"@getpaseo/relay": "0.1.63-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.67.68",
"@mariozechner/pi-ai": "^0.67.68",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -1171,6 +1171,125 @@ test("requests branch suggestions via RPC", async () => {
});
});
test("reads project config via correlated RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: { type: "read_project_config_request"; requestId: string; repoRoot: string };
};
expect(request.message).toEqual({
type: "read_project_config_request",
requestId: "read-project-config-1",
repoRoot: "/repo/app",
});
mock.triggerMessage(
wrapSessionMessage({
type: "read_project_config_response",
payload: {
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
});
});
test("writes project config via correlated RPC and returns inline failures", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.writeProjectConfig({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "write_project_config_request";
requestId: string;
repoRoot: string;
config: unknown;
expectedRevision: unknown;
};
};
expect(request.message).toEqual({
type: "write_project_config_request",
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
mock.triggerMessage(
wrapSessionMessage({
type: "write_project_config_response",
payload: {
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
});
});
test("requests directory suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -68,6 +68,8 @@ import type {
SessionOutboundMessage,
SendAgentMessageRequest,
EditorTargetId,
PaseoConfigRaw,
PaseoConfigRevision,
} from "../shared/messages.js";
import type {
AgentPermissionRequest,
@@ -274,11 +276,25 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ReadProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "read_project_config_response" }
>["payload"];
type WriteProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "write_project_config_response" }
>["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>;
export interface WriteProjectConfigInput {
repoRoot: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
requestId?: string;
}
interface ListCommandsOptions {
requestId?: string;
draftConfig?: ListCommandsDraftConfig;
@@ -2940,6 +2956,32 @@ export class DaemonClient {
});
}
async readProjectConfig(repoRoot: string, requestId?: string): Promise<ReadProjectConfigPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "read_project_config_request",
repoRoot,
},
responseType: "read_project_config_response",
timeout: 10000,
});
}
async writeProjectConfig(input: WriteProjectConfigInput): Promise<WriteProjectConfigPayload> {
return this.sendCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "write_project_config_request",
repoRoot: input.repoRoot,
config: input.config,
expectedRevision: input.expectedRevision,
},
responseType: "write_project_config_response",
timeout: 10000,
});
}
async refreshProvidersSnapshot(options?: {
cwd?: string;
providers?: AgentProvider[];
@@ -7,6 +7,7 @@ import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
isStoredAgentProviderAvailable,
toAgentPersistenceHandle,
} from "../persistence-hooks.js";
@@ -40,7 +41,11 @@ export async function ensureAgentLoaded(
}
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
const handle = toAgentPersistenceHandle(deps.logger, validProviders, record.persistence);
if (!isStoredAgentProviderAvailable(record, validProviders)) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
const handle = toAgentPersistenceHandle(validProviders, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
@@ -54,7 +59,6 @@ export async function ensureAgentLoaded(
} else {
const config = buildSessionConfig(record, {
validProviders,
logger: deps.logger,
});
if (!config) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
@@ -11,6 +11,7 @@ import type {
AgentClient,
AgentFeature,
AgentLaunchContext,
AgentProvider,
AgentPersistenceHandle,
AgentRunResult,
AgentSession,
@@ -18,6 +19,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
} from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js";
interface Deferred<T> {
promise: Promise<T>;
@@ -78,6 +80,11 @@ class TestAgentClient implements AgentClient {
id: "gpt-5.4-mini",
label: "GPT-5.4 Mini",
},
{
provider: "codex",
id: "gpt-5.2-codex",
label: "GPT-5.2 Codex",
},
];
}
@@ -787,6 +794,199 @@ test("createAgent fails when cwd does not exist", async () => {
).rejects.toThrow("Working directory does not exist");
});
test("createAgent reports configured providers when provider is unknown", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
});
await expect(
manager.createAgent({
provider: "missing-provider",
cwd: workdir,
}),
).rejects.toThrow("Unknown provider 'missing-provider'. Configured providers: codex.");
});
test("createAgent reports available providers when selected provider is unavailable", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class UnavailableCodexClient extends TestAgentClient {
override async isAvailable(): Promise<boolean> {
return false;
}
}
const manager = new AgentManager({
clients: {
codex: new UnavailableCodexClient(),
claude: new TestAgentClient(),
},
registry: storage,
logger,
});
await expect(
manager.createAgent({
provider: "codex",
cwd: workdir,
}),
).rejects.toThrow(
"Provider 'codex' is not available. Available providers: claude. Use one of those providers, or install/configure 'codex'.",
);
});
test("createAgent rejects a disabled provider without creating a session", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class DisabledCodexClient extends TestAgentClient {
createSessionCalls = 0;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.createSessionCalls += 1;
return await super.createSession(config);
}
}
const disabledClient = new DisabledCodexClient();
const providerDefinitions = {
codex: {
enabled: false,
},
} satisfies Partial<Record<AgentProvider, Pick<ProviderDefinition, "enabled">>>;
const manager = new AgentManager({
clients: {
codex: disabledClient,
},
providerDefinitions,
registry: storage,
logger,
});
await expect(
manager.createAgent({
provider: "codex",
cwd: workdir,
}),
).rejects.toThrow("Provider 'codex' is disabled");
expect(disabledClient.createSessionCalls).toBe(0);
expect(await storage.list()).toHaveLength(0);
});
test("updateProviderRegistry re-enables a previously disabled provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
const manager = new AgentManager({
clients: { codex: client },
providerDefinitions: {
codex: { enabled: false },
},
registry: storage,
logger,
});
await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
"Provider 'codex' is disabled",
);
manager.updateProviderRegistry({
providerDefinitions: { codex: { enabled: true } },
clients: { codex: client },
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
expect(snapshot.config.provider).toBe("codex");
});
test("updateProviderRegistry disables a previously enabled provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
const manager = new AgentManager({
clients: { codex: client },
providerDefinitions: {
codex: { enabled: true },
},
registry: storage,
logger,
});
manager.updateProviderRegistry({
providerDefinitions: { codex: { enabled: false } },
clients: { codex: client },
});
await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
"Provider 'codex' is disabled",
);
});
test("updateProviderRegistry registers a previously unknown provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {},
providerDefinitions: {},
registry: storage,
logger,
});
expect(manager.getRegisteredProviderIds()).not.toContain("codex");
manager.updateProviderRegistry({
providerDefinitions: { codex: { enabled: true } },
clients: { codex: new TestAgentClient() },
});
expect(manager.getRegisteredProviderIds()).toContain("codex");
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
expect(snapshot.config.provider).toBe("codex");
});
test("createAgent passes explicit model strings through to the provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureModelClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new TestAgentSession(config);
}
}
const client = new CaptureModelClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
});
await manager.createAgent({
provider: "codex",
cwd: workdir,
model: "not-a-real-model",
});
expect(client.lastConfig?.model).toBe("not-a-real-model");
});
test("resumeAgentFromPersistence keeps metadata config, applies overrides, and passes launch env", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-"));
const storagePath = join(workdir, "agents");
@@ -61,6 +61,10 @@ interface TimeoutOptions {
onLateError?: (error: unknown) => void;
}
function formatProviderList(providers: readonly string[]): string {
return providers.length > 0 ? providers.join(", ") : "none";
}
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
export type {
AgentTimelineCursor,
@@ -109,8 +113,15 @@ interface AgentManagerRescueTimeouts {
interruptSessionMs?: number;
}
interface ProviderEnabledFlag {
enabled: boolean;
}
type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
export interface AgentManagerOptions {
clients?: Partial<Record<AgentProvider, AgentClient>>;
clients?: ProviderClientMap;
providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
registry?: AgentStorage;
onAgentAttention?: AgentAttentionCallback;
@@ -383,6 +394,7 @@ function buildExplicitTimelineSeedForRegister(
export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
@@ -421,19 +433,32 @@ export class AgentManager {
this.notifyForegroundTurnWaiters(agentId, event);
},
});
if (options?.clients) {
for (const [provider, client] of Object.entries(options.clients)) {
if (client) {
this.registerClient(provider as AgentProvider, client);
}
}
}
this.updateProviderRegistry({
providerDefinitions: options.providerDefinitions ?? {},
clients: options.clients ?? {},
});
}
registerClient(provider: AgentProvider, client: AgentClient): void {
this.clients.set(provider, client);
}
updateProviderRegistry(input: {
providerDefinitions: ProviderEnabledMap;
clients: ProviderClientMap;
}): void {
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
if (definition) {
this.providerEnabled.set(provider, definition.enabled);
}
}
for (const [provider, client] of Object.entries(input.clients)) {
if (client) {
this.clients.set(provider, client);
}
}
}
getRegisteredProviderIds(): AgentProvider[] {
return Array.from(this.clients.keys());
}
@@ -714,15 +739,12 @@ export class AgentManager {
...config.mcpServers,
},
};
this.requireEnabledProvider(injectedConfig.provider);
const normalizedConfig = await this.normalizeConfig(injectedConfig);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const client = await this.requireAvailableClient({
provider: normalizedConfig.provider,
});
const session = await client.createSession(normalizedConfig, launchContext);
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
labels: options?.labels,
@@ -3079,6 +3101,47 @@ export class AgentManager {
};
}
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
const client = this.clients.get(options.provider);
if (!client) {
const configuredProviders = this.getConfiguredProviderIds();
throw new Error(
`Unknown provider '${options.provider}'. Configured providers: ${formatProviderList(
configuredProviders,
)}.`,
);
}
let unavailableReason: string | null = null;
try {
const available = await client.isAvailable();
if (available) {
return client;
}
} catch (error) {
unavailableReason = error instanceof Error ? error.message : String(error);
}
const availableProviders = (await this.listProviderAvailability())
.filter((entry) => entry.available)
.map((entry) => entry.provider);
const providerList = formatProviderList(availableProviders);
const reason = unavailableReason ? ` Reason: ${unavailableReason}.` : "";
throw new Error(
`Provider '${options.provider}' is not available.${reason} Available providers: ${providerList}. Use one of those providers, or install/configure '${options.provider}'.`,
);
}
private requireEnabledProvider(provider: AgentProvider): void {
if (this.providerEnabled.get(provider) === false) {
throw new Error(`Provider '${provider}' is disabled`);
}
}
private getConfiguredProviderIds(): AgentProvider[] {
return Array.from(new Set([...this.providerEnabled.keys(), ...this.clients.keys()]));
}
private requireClient(provider: AgentProvider): AgentClient {
const client = this.clients.get(provider);
if (!client) {
@@ -7,16 +7,14 @@ import type {
AgentMode,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentProvider,
AgentSessionConfig,
AgentRuntimeInfo,
AgentUsage,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js";
import type { Logger } from "pino";
import { buildProviderRegistry } from "./provider-registry.js";
import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-hooks.js";
import { isStoredAgentProviderAvailable, toAgentPersistenceHandle } from "../persistence-hooks.js";
export type { ManagedAgent };
interface ProjectionOptions {
@@ -143,15 +141,11 @@ export function toAgentPayload(
return payload;
}
function buildStoredRuntimeInfo(
record: StoredAgentRecord,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
logger: Logger,
): AgentRuntimeInfo | undefined {
function buildStoredRuntimeInfo(record: StoredAgentRecord): AgentRuntimeInfo | undefined {
if (!record.runtimeInfo) return undefined;
const ri = record.runtimeInfo;
const runtimeInfo: AgentRuntimeInfo = {
provider: coerceAgentProvider(logger, providerRegistry, ri.provider, record.id),
provider: ri.provider,
sessionId: ri.sessionId,
};
if (Object.prototype.hasOwnProperty.call(ri, "model")) {
@@ -169,10 +163,19 @@ function buildStoredRuntimeInfo(
return runtimeInfo;
}
function buildStoredPersistenceHandle(
record: StoredAgentRecord,
validProviders: Iterable<AgentProvider>,
): AgentPersistenceHandle | null {
if (!isStoredAgentProviderAvailable(record, validProviders)) {
return null;
}
return toAgentPersistenceHandle(validProviders, record.persistence);
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
logger: Logger,
validProviders: Iterable<AgentProvider>,
): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
@@ -187,12 +190,13 @@ export function buildStoredAgentPayload(
const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record));
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id);
const runtimeInfo = buildStoredRuntimeInfo(record, providerRegistry, logger);
const runtimeInfo = buildStoredRuntimeInfo(record);
const providerAvailable = isStoredAgentProviderAvailable(record, validProviders);
const persistence = buildStoredPersistenceHandle(record, validProviders);
return {
id: record.id,
provider,
provider: record.provider,
cwd: record.cwd,
model: record.config?.model ?? null,
thinkingOptionId: record.config?.thinkingOptionId ?? null,
@@ -209,13 +213,14 @@ export function buildStoredAgentPayload(
currentModeId: record.lastModeId ?? null,
availableModes: [],
pendingPermissions: [],
persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence),
persistence,
title: record.title ?? record.config?.title ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
labels: normalizeLabels(record.labels),
...(providerAvailable ? {} : { providerUnavailable: true }),
};
}
@@ -238,6 +243,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp ?? null,
labels: agent.labels,
...(agent.providerUnavailable ? { providerUnavailable: true } : {}),
};
}
@@ -74,6 +74,7 @@ export interface AgentSelectOption {
export interface ProviderSnapshotEntry {
provider: AgentProvider;
status: ProviderStatus;
enabled: boolean;
error?: string;
models?: AgentModelDefinition[];
modes?: AgentMode[];
@@ -141,13 +141,33 @@ function createTestDeps(): TestDeps {
}
function createProviderDefinition(overrides: Partial<ProviderDefinition>): ProviderDefinition {
const provider = (overrides.id ?? "claude") as AgentProvider;
return {
id: "claude",
id: provider,
label: "Claude",
description: "Test provider",
enabled: true,
defaultModeId: "default",
modes: [],
createClient: vi.fn() as ProviderDefinition["createClient"],
createClient: vi.fn(() => ({
provider,
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
createSession: async () => {
throw new Error("createSession is not used by this MCP provider test");
},
resumeSession: async () => {
throw new Error("resumeSession is not used by this MCP provider test");
},
listModels: vi.fn().mockResolvedValue([]),
isAvailable: vi.fn().mockResolvedValue(true),
})),
fetchModels: vi.fn().mockResolvedValue([]),
fetchModes: vi.fn().mockResolvedValue([]),
...overrides,
@@ -913,7 +933,7 @@ describe("create_agent MCP tool", () => {
describe("create_schedule MCP tool", () => {
const logger = createTestLogger();
it("preserves default new-agent schedule behavior without requiring provider", async () => {
it("requires provider for new-agent schedules", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const server = await createAgentMcpServer({
@@ -924,31 +944,14 @@ describe("create_schedule MCP tool", () => {
});
const tool = registeredTool(server, "create_schedule");
const response = await tool.callback({
prompt: "say hello",
every: "5m",
name: "Default schedule",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
await expect(
tool.callback({
prompt: "say hello",
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
},
every: "5m",
name: "Default schedule",
}),
);
expect(response.structuredContent.target).toEqual({
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
});
).rejects.toThrow("provider is required when target is new-agent");
expect(create).not.toHaveBeenCalled();
});
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
@@ -1035,16 +1038,125 @@ describe("provider listing MCP tool", () => {
{
id: "claude",
label: "Claude",
description: "Test provider",
enabled: true,
status: "available",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
},
{
id: "zai",
label: "ZAI",
status: "available",
description: "Custom Claude profile",
enabled: true,
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
},
],
});
});
it("returns disabled providers with metadata without checking availability", async () => {
const { agentManager, agentStorage } = createTestDeps();
const baseProvider = createProviderDefinition({ id: "codex" });
const client = baseProvider.createClient(logger);
const isAvailable = vi.fn().mockResolvedValue(true);
const createClient = vi.fn(() => ({ ...client, isAvailable }));
const providerRegistry = {
codex: createProviderDefinition({
id: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
createClient,
}),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerRegistry,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.callback({});
expect(response.structuredContent).toEqual({
providers: [
{
id: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
status: "unavailable",
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
},
],
});
expect(createClient).not.toHaveBeenCalled();
expect(isAvailable).not.toHaveBeenCalled();
});
it("checks availability for enabled providers", async () => {
const { agentManager, agentStorage } = createTestDeps();
const baseProvider = createProviderDefinition({ id: "claude" });
const client = baseProvider.createClient(logger);
const isAvailable = vi.fn().mockResolvedValue(true);
const providerRegistry = {
claude: createProviderDefinition({
createClient: vi.fn(() => ({ ...client, isAvailable })),
}),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerRegistry,
logger,
});
const tool = registeredTool(server, "list_providers");
await tool.callback({});
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
expect(isAvailable).toHaveBeenCalledTimes(1);
});
});
describe("model listing MCP tool", () => {
const logger = createTestLogger();
it("rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const fetchModels = vi.fn().mockResolvedValue([
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
]);
const providerRegistry = {
codex: createProviderDefinition({
id: "codex",
label: "Codex",
enabled: false,
fetchModels,
}),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerRegistry,
logger,
});
const tool = registeredTool(server, "list_models");
await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
expect(fetchModels).not.toHaveBeenCalled();
});
});
describe("speak MCP tool", () => {
+75 -27
View File
@@ -22,6 +22,7 @@ import {
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -159,6 +160,52 @@ function resolveAgentListActivityTime(agent: AgentListItemPayload): number {
);
}
function resolveRegisteredProviderIds(
agentManager: AgentManager,
providerRegistry: Record<AgentProvider, ProviderDefinition> | null | undefined,
): AgentProvider[] {
return providerRegistry
? (Object.keys(providerRegistry) as AgentProvider[])
: agentManager.getRegisteredProviderIds();
}
interface ProviderSummary {
id: AgentProvider;
label: string;
description: string;
enabled: boolean;
modes: ProviderDefinition["modes"];
status: string;
error?: string;
}
async function resolveProviderSummary(
provider: ProviderDefinition,
logger: Logger,
): Promise<ProviderSummary> {
const base = {
id: provider.id,
label: provider.label,
description: provider.description,
modes: provider.modes,
};
if (!provider.enabled) {
return { ...base, enabled: false, status: "unavailable" };
}
try {
const available = await provider.createClient(logger).isAvailable();
return { ...base, enabled: true, status: available ? "available" : "unavailable" };
} catch (availabilityError) {
return {
...base,
enabled: true,
status: "unavailable",
error:
availabilityError instanceof Error ? availabilityError.message : String(availabilityError),
};
}
}
function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number {
const attentionDelta =
Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false);
@@ -289,13 +336,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
version: "2.0.0",
});
const requireProviderRegistry = (): Record<AgentProvider, ProviderDefinition> => {
if (!providerRegistry) {
throw new Error("Provider registry is required to load stored agent records");
}
return providerRegistry;
};
const resolveCallerAgent = () => {
if (!callerAgentId) {
return null;
@@ -389,6 +429,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
if (!params?.provider?.trim()) {
throw new Error("provider is required when target is new-agent");
}
const callerAgent = resolveCallerAgent();
if (callerAgent) {
return {
@@ -399,7 +443,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const resolvedProviderModel = resolveScheduleProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
defaultProvider: params.provider as AgentProvider,
});
return {
type: "new-agent" as const,
@@ -675,7 +719,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "Create agent",
description:
"Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Optionally run an initial prompt immediately or create a git worktree for the agent.",
"Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Do not guess; call list_providers and list_models first if uncertain. Optionally run an initial prompt immediately or create a git worktree for the agent.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
@@ -1052,8 +1096,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const structuredSnapshot = buildStoredAgentPayload(
record,
requireProviderRegistry(),
childLogger,
resolveRegisteredProviderIds(agentManager, providerRegistry),
);
return {
content: [],
@@ -1100,10 +1143,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
);
const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id));
const storedRecords = await agentStorage.list();
const registeredProviderIds = resolveRegisteredProviderIds(agentManager, providerRegistry);
const storedAgents = storedRecords
.filter((record) => !record.internal && !liveIds.has(record.id))
.filter((record) => includeArchived || !record.archivedAt)
.map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger));
.filter(
(record) =>
includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds),
)
.map((record) => buildStoredAgentPayload(record, registeredProviderIds));
const agents = [...liveAgents, ...storedAgents]
.map(toAgentListItemPayload)
.filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd))
@@ -1631,26 +1679,23 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"list_providers",
{
title: "List providers",
description: "List available agent providers and their modes.",
description: "List configured agent providers, availability, and their modes.",
inputSchema: {},
outputSchema: {
providers: z.array(ProviderSummarySchema),
},
},
async () => ({
content: [],
structuredContent: ensureValidJson({
providers: Object.values(providerRegistry ?? {}).map((provider) => ({
id: provider.id,
label: provider.label,
modes: provider.modes.map((mode) => ({
id: mode.id,
label: mode.label,
...(mode.description ? { description: mode.description } : {}),
})),
})),
}),
}),
async () => {
const providers = await Promise.all(
Object.values(providerRegistry ?? {}).map((provider) =>
resolveProviderSummary(provider, childLogger),
),
);
return {
content: [],
structuredContent: ensureValidJson({ providers }),
};
},
);
server.registerTool(
@@ -1675,6 +1720,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!definition) {
throw new Error(`Provider ${provider} is not configured`);
}
if (!definition.enabled) {
throw new Error(`Provider '${provider}' is disabled`);
}
const models = await definition.fetchModels({ cwd: resolveSnapshotCwd(), force: false });
return {
@@ -32,6 +32,8 @@ export const ProviderSummarySchema = z
.object({
id: z.string(),
label: z.string().nullish(),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
modes: z.array(ProviderModeSchema).nullish(),
})
.passthrough();
@@ -5,6 +5,7 @@ import { resolveAgentModel } from "./model-resolver.js";
vi.mock("./provider-registry.js", () => ({
buildProviderRegistry: vi.fn(),
isProviderEnabled: vi.fn((definition: { enabled: boolean }) => definition.enabled === true),
}));
import { buildProviderRegistry } from "./provider-registry.js";
@@ -21,6 +22,12 @@ describe("resolveAgentModel", () => {
});
it("returns the trimmed requested model when provided", async () => {
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels: vi.fn() },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({
provider: "codex",
requestedModel: " gpt-5.1 ",
@@ -29,7 +36,7 @@ describe("resolveAgentModel", () => {
});
expect(result).toBe("gpt-5.1");
expect(mockedBuildProviderRegistry).not.toHaveBeenCalled();
expect(mockedBuildProviderRegistry).toHaveBeenCalledWith(testLogger);
});
it("uses the default model from the provider catalog when no model specified", async () => {
@@ -38,9 +45,9 @@ describe("resolveAgentModel", () => {
{ id: "claude-3.5-sonnet", isDefault: true },
]);
mockedBuildProviderRegistry.mockReturnValue({
claude: { fetchModels },
codex: { fetchModels: vi.fn() },
opencode: { fetchModels: vi.fn() },
claude: { enabled: true, fetchModels },
codex: { enabled: true, fetchModels: vi.fn() },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({
@@ -62,9 +69,9 @@ describe("resolveAgentModel", () => {
{ id: "model-b", isDefault: false },
]);
mockedBuildProviderRegistry.mockReturnValue({
claude: { fetchModels: vi.fn() },
codex: { fetchModels },
opencode: { fetchModels: vi.fn() },
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
@@ -75,9 +82,9 @@ describe("resolveAgentModel", () => {
it("returns undefined when the catalog lookup fails", async () => {
const fetchModels = vi.fn().mockRejectedValue(new Error("boom"));
mockedBuildProviderRegistry.mockReturnValue({
claude: { fetchModels: vi.fn() },
codex: { fetchModels },
opencode: { fetchModels: vi.fn() },
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
@@ -85,4 +92,38 @@ describe("resolveAgentModel", () => {
expect(result).toBeUndefined();
expect(testLoggerWarn).toHaveBeenCalled();
});
it("returns undefined for a disabled provider without fetching default models", async () => {
const fetchModels = vi.fn().mockResolvedValue([{ id: "model-a", isDefault: true }]);
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: false, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
expect(result).toBeUndefined();
expect(fetchModels).not.toHaveBeenCalled();
expect(testLoggerWarn).toHaveBeenCalled();
});
it("returns undefined for a requested model from a disabled provider", async () => {
const fetchModels = vi.fn();
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: false, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({
provider: "codex",
requestedModel: "gpt-5.1",
logger: testLogger,
});
expect(result).toBeUndefined();
expect(fetchModels).not.toHaveBeenCalled();
expect(testLoggerWarn).toHaveBeenCalled();
});
});
@@ -14,14 +14,19 @@ interface ResolveAgentModelOptions {
export async function resolveAgentModel(
options: ResolveAgentModelOptions,
): Promise<string | undefined> {
const trimmed = options.requestedModel?.trim();
if (trimmed) {
return trimmed;
}
try {
const providerRegistry = buildProviderRegistry(options.logger);
const models = await providerRegistry[options.provider].fetchModels({
const providerDefinition = providerRegistry[options.provider];
if (!providerDefinition.enabled) {
throw new Error(`Provider '${options.provider}' is disabled`);
}
const trimmed = options.requestedModel?.trim();
if (trimmed) {
return trimmed;
}
const models = await providerDefinition.fetchModels({
cwd: resolveSnapshotCwd(options.cwd ? expandTilde(options.cwd) : undefined),
force: false,
});
@@ -317,7 +317,11 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
},
}));
import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry } from "./provider-registry.js";
import {
AGENT_PROVIDER_DEFINITIONS,
buildProviderRegistry,
createAllClients,
} from "./provider-registry.js";
const logger = createTestLogger();
@@ -458,7 +462,7 @@ test("custom provider without label throws", () => {
).toThrowError("Custom provider 'zai' requires a label");
});
test("enabled: false excludes provider from registry", () => {
test("enabled: false keeps provider metadata in registry", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
@@ -467,7 +471,31 @@ test("enabled: false excludes provider from registry", () => {
},
});
expect(registry.claude).toBeUndefined();
expect(registry.claude).toMatchObject({
id: "claude",
label: "Claude",
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
defaultModeId: "default",
enabled: false,
});
expect(registry.claude.modes).toEqual(
AGENT_PROVIDER_DEFINITIONS.find((definition) => definition.id === "claude")?.modes,
);
expect(registry.codex.enabled).toBe(true);
});
test("enabled: false still produces a client (enabled gate is enforced elsewhere)", () => {
const clients = createAllClients(logger, {
providerOverrides: {
claude: {
enabled: false,
},
},
});
expect(clients.claude).toBeDefined();
expect(mockState.constructorArgs.claude.length).toBeGreaterThan(0);
expect(clients.codex).toBeDefined();
});
test("provider override command can be PATH-resolved and still report available", async () => {
@@ -41,6 +41,7 @@ export type { AgentProviderDefinition };
export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition {
enabled: boolean;
createClient: (logger: Logger) => AgentClient;
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
@@ -364,10 +365,9 @@ function createRegistryEntry(
return {
...resolved.definition,
createClient: (providerLogger: Logger) => {
const inner = resolved.createBaseClient(providerLogger);
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
},
enabled: resolved.enabled,
createClient: (providerLogger: Logger) =>
createResolvedProviderClient(providerLogger, provider, resolved),
fetchModels: async (options: ListModelsOptions) =>
mergeModels(
provider,
@@ -392,6 +392,15 @@ function createRegistryEntry(
};
}
function createResolvedProviderClient(
logger: Logger,
provider: AgentProvider,
resolved: ResolvedProvider,
): AgentClient {
const inner = resolved.createBaseClient(logger);
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
}
function buildResolvedBuiltinProviders(
providerOverrides: Record<string, ProviderOverride>,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
@@ -514,9 +523,10 @@ export function buildProviderRegistry(
addDerivedProviders(resolvedProviders, providerOverrides);
return Object.fromEntries(
[...resolvedProviders.entries()]
.filter(([, resolved]) => resolved.enabled)
.map(([provider, resolved]) => [provider, createRegistryEntry(logger, provider, resolved)]),
[...resolvedProviders.entries()].map(([provider, resolved]) => [
provider,
createRegistryEntry(logger, provider, resolved),
]),
) as Record<AgentProvider, ProviderDefinition>;
}
@@ -534,7 +544,13 @@ export function createAllClients(
logger: Logger,
options?: BuildProviderRegistryOptions,
): Record<AgentProvider, AgentClient> {
const registry = buildProviderRegistry(logger, options);
return createClientsFromRegistry(buildProviderRegistry(logger, options), logger);
}
export function createClientsFromRegistry(
registry: Record<AgentProvider, ProviderDefinition>,
logger: Logger,
): Record<AgentProvider, AgentClient> {
return Object.fromEntries(
Object.entries(registry).map(([provider, definition]) => [
provider,
@@ -23,9 +23,11 @@ interface Deferred<T> {
interface MockProviderOptions {
provider: AgentProvider;
enabled?: boolean;
label?: string;
description?: string;
defaultModeId?: string | null;
modes?: AgentMode[];
isAvailable?: () => Promise<boolean>;
fetchModels?: (cwd: string, force: boolean) => Promise<AgentModelDefinition[]>;
fetchModes?: (cwd: string, force: boolean) => Promise<AgentMode[]>;
@@ -33,6 +35,7 @@ interface MockProviderOptions {
interface MockProviderHandle {
definition: ProviderDefinition;
createClient: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
fetchModels: ReturnType<typeof vi.fn>;
fetchModes: ReturnType<typeof vi.fn>;
@@ -158,6 +161,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "unavailable",
enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -189,6 +193,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "error",
enabled: true,
error: "model lookup failed",
label: "codex",
description: "codex test provider",
@@ -278,6 +283,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "loading",
enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -385,6 +391,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "loading",
enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -392,6 +399,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "claude",
status: "loading",
enabled: true,
label: "claude",
description: "claude test provider",
defaultModeId: null,
@@ -465,6 +473,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "codex",
status: "loading",
enabled: true,
label: "codex",
description: "codex test provider",
defaultModeId: null,
@@ -1047,6 +1056,49 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
test("replaceRegistry removes providers that were disabled at runtime", async () => {
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async () => [createModel("codex", "gpt-5.2")],
}),
createMockProvider({
provider: "claude",
fetchModels: async () => [createModel("claude", "sonnet")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
const listener = vi.fn<(entries: ProviderSnapshotEntry[], cwd: string) => void>();
manager.on("change", listener);
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
const { registry: nextRegistry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async () => [createModel("codex", "gpt-5.2")],
}),
]);
manager.replaceRegistry(nextRegistry);
expect(manager.getSnapshot(projectCwd).map((entry) => entry.provider)).toEqual(["codex"]);
expect(listener).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
provider: "codex",
}),
]),
homedir(),
);
manager.destroy();
});
test("snapshot includes user-defined providers from the registry", async () => {
const { registry } = createRegistry([
createMockProvider({ provider: "claude" }),
@@ -1077,6 +1129,72 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
test("disabled providers stay in the snapshot without probing or fetching", async () => {
const disabledModels = [createModel("zai", "glm-4.6")];
const disabledMode = createMode("plan");
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async () => [createModel("codex", "gpt-5.2")],
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "zai",
enabled: false,
label: "Z.AI",
description: "Custom disabled Claude profile",
defaultModeId: "plan",
models: disabledModels,
modes: [disabledMode],
fetchModels: async () => [createModel("zai", "glm-4.6")],
fetchModes: async () => [disabledMode],
}),
createMockProvider({
provider: "claude",
fetchModels: async () => [createModel("claude", "sonnet")],
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("unavailable");
});
const snapshot = manager.getSnapshot(projectCwd);
expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "zai", "claude"]);
const zaiEntry = getProviderEntry(snapshot, "zai");
expect(zaiEntry).toMatchObject({
provider: "zai",
status: "unavailable",
enabled: false,
label: "Z.AI",
description: "Custom disabled Claude profile",
defaultModeId: "plan",
});
expect(zaiEntry?.models).toBeUndefined();
expect(zaiEntry?.modes).toBeUndefined();
expect(handles.zai?.createClient).not.toHaveBeenCalled();
expect(handles.zai?.isAvailable).not.toHaveBeenCalled();
expect(handles.zai?.fetchModels).not.toHaveBeenCalled();
expect(handles.zai?.fetchModes).not.toHaveBeenCalled();
expect(handles.codex?.createClient).toHaveBeenCalledTimes(1);
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
expect(handles.claude?.createClient).toHaveBeenCalledTimes(1);
expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1);
expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1);
expect(handles.claude?.fetchModes).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("enabled false providers are omitted when absent from the registry", () => {
const { registry } = createRegistry([createMockProvider({ provider: "claude" })]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
@@ -1108,6 +1226,7 @@ describe("ProviderSnapshotManager", () => {
{
provider: "zai",
status: "loading",
enabled: true,
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "plan",
@@ -1157,6 +1276,7 @@ function createRegistry(handles: MockProviderHandle[]): {
}
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
const createClient = vi.fn();
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
const fetchModels = vi.fn(
async (listOptions: ListModelsOptions) =>
@@ -1171,14 +1291,16 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
],
);
const definition: ProviderDefinition = {
const definition = {
id: options.provider,
enabled: options.enabled ?? true,
label: options.label ?? options.provider,
description: options.description ?? `${options.provider} test provider`,
defaultModeId: options.defaultModeId ?? null,
modes: [],
createClient: () =>
({
modes: options.modes ?? [],
createClient: () => {
createClient();
return {
provider: options.provider,
capabilities: TEST_CAPABILITIES,
async createSession() {
@@ -1193,13 +1315,15 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
async isAvailable() {
return isAvailable();
},
}) satisfies AgentClient,
} satisfies AgentClient;
},
fetchModels,
fetchModes,
};
} satisfies ProviderDefinition;
return {
definition,
createClient,
isAvailable,
fetchModels,
fetchModes,

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