Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 16d321899c fix(app): select current branch for new workspaces
Closes #541
2026-04-24 13:50:22 +07:00
182 changed files with 2762 additions and 16577 deletions
+2 -23
View File
@@ -1,37 +1,16 @@
# Changelog
## 0.1.63-beta.2 - 2026-04-26
### Added
- Project settings page with a built-in `paseo.json` editor.
- Cold start restores your last open workspace.
- Tool call badges have a button to open the referenced file directly.
- Enable or disable providers from Settings without editing config files.
- Paseo prompts you to configure a worktree setup script when one is missing.
## 0.1.63-beta.1 - 2026-04-24
### Improved
- Provider settings and model selection have been redesigned, with switches replacing toggle buttons.
- Adding a project no longer waits for GitHub PR status to load.
- Startup splash screen is cleaner — just the logo with a subtle shimmer.
- Desktop quit flow is smoother with a clearer shutdown dialog.
- `paseo.json` setup and teardown accept a single command string, not just an array.
- Archiving a worktree is instant instead of waiting for the backend to confirm.
- Terminal sessions recover cleanly after rendering hiccups.
- Agent timelines and git diff lists no longer jump around while loading or streaming.
### Fixed
- Terminal sessions recover cleanly after rendering hiccups, including the initial resize for nvim.
- Terminal protocol query responses no longer leak into the browser.
- Assistant link color matches the theme again.
- File links with line numbers (like `foo.ts:42`) open correctly from assistant messages.
- Claude's Grep results show up in the search detail body.
- Reopening a worktree lands under the right project.
- Agents from disabled or unavailable providers stay visible in history.
- New CLI agents now require a provider instead of failing silently.
- Git diff headers no longer truncate.
- The packaged desktop app preserves your zsh prompt.
## 0.1.62 - 2026-04-23
-247
View File
@@ -1,247 +0,0 @@
# 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 sidebar callout actions (`packages/app/src/components/sidebar-callout.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 `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.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`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) 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 `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- 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 alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-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,18 +62,6 @@ 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
@@ -1,45 +0,0 @@
# 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.
-68
View File
@@ -4,74 +4,6 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Forbidden
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.
```tsx
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.surface0,
padding: theme.spacing[4],
},
}));
<View style={styles.container} />;
```
If you are reading a theme value just to feed it back into a `style` prop, you almost certainly want this and not the hook.
### 2. Hard-coded constants for genuinely static values
If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static.
### 3. `withUnistyles(Component)` for third-party props
When a third-party component takes a non-`style` prop that must be theme-reactive (e.g. `BlurView.tint`, `Image.tintColor`, navigator option props, bottom-sheet `backgroundStyle`), wrap that single component with `withUnistyles`. Only the wrapper re-renders, not the surrounding tree.
```tsx
const ThemedBlur = withUnistyles(BlurView);
<ThemedBlur tint={theme.colors.surface0} />;
```
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection`
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, tabs row, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
+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-6Bf44iWdDwe2MeHyS1Dj0Xhl5TNYuf50CI5uQHsQ97A=";
npmDepsHash = "sha256-0Izp63gVeBO9Efo4Uf8/EB3TkQ9FXJ4Td2Fe5UHm9eE=";
# 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).
+363 -412
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -126,26 +126,6 @@
"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",
@@ -8463,31 +8443,31 @@
}
},
"node_modules/@mariozechner/clipboard": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.3.tgz",
"integrity": "sha512-e7jASirzfm+ROiOGFh843+cFZTy3DfzP+jldCvh8RnEk0C3QihDTn7dd7Yh7KAJydwIJ18FJSZ2swHvCJhk18g==",
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz",
"integrity": "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@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"
"@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"
}
},
"node_modules/@mariozechner/clipboard-darwin-arm64": {
"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==",
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.2.tgz",
"integrity": "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==",
"cpu": [
"arm64"
],
@@ -8501,9 +8481,9 @@
}
},
"node_modules/@mariozechner/clipboard-darwin-universal": {
"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==",
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.2.tgz",
"integrity": "sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==",
"license": "MIT",
"optional": true,
"os": [
@@ -8514,9 +8494,9 @@
}
},
"node_modules/@mariozechner/clipboard-darwin-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -8530,9 +8510,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-arm64-gnu": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -8546,9 +8526,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -8562,9 +8542,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
"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==",
"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==",
"cpu": [
"riscv64"
],
@@ -8578,9 +8558,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -8594,9 +8574,9 @@
}
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -8610,9 +8590,9 @@
}
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -8626,9 +8606,9 @@
}
},
"node_modules/@mariozechner/clipboard-win32-x64-msvc": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -8654,330 +8634,6 @@
"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",
@@ -36560,12 +36216,6 @@
"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",
@@ -38414,14 +38064,14 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.63-beta.2",
"@getpaseo/highlight": "0.1.63-beta.2",
"@getpaseo/expo-two-way-audio": "0.1.63-beta.1",
"@getpaseo/highlight": "0.1.63-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -38540,10 +38190,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.63-beta.2",
"@getpaseo/server": "0.1.63-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38585,10 +38235,10 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/server": "0.1.63-beta.2",
"@getpaseo/server": "0.1.63-beta.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -38623,7 +38273,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -38659,7 +38309,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -38685,7 +38335,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38700,16 +38350,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.63-beta.2",
"@getpaseo/relay": "0.1.63-beta.2",
"@getpaseo/highlight": "0.1.63-beta.1",
"@getpaseo/relay": "0.1.63-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@mariozechner/pi-agent-core": "^0.67.68",
"@mariozechner/pi-ai": "^0.67.68",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -38751,6 +38401,26 @@
"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",
@@ -38760,6 +38430,147 @@
"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",
@@ -38847,6 +38658,18 @@
"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",
@@ -38876,6 +38699,23 @@
"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",
@@ -38888,6 +38728,15 @@
"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",
@@ -38908,6 +38757,30 @@
"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",
@@ -38955,6 +38828,44 @@
"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",
@@ -38964,6 +38875,15 @@
"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",
@@ -38997,6 +38917,21 @@
"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",
@@ -39021,6 +38956,22 @@
"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",
@@ -39156,7 +39107,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
-3
View File
@@ -37,9 +37,6 @@ yarn-error.*
# typescript
*.tsbuildinfo
# vitest browser failure screenshots
.vitest-screenshots/
app-example
# generated native folders
-119
View File
@@ -1,119 +0,0 @@
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,7 +1,6 @@
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,
@@ -11,54 +10,6 @@ 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);
@@ -102,8 +53,6 @@ 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 () => {
@@ -189,62 +138,4 @@ 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)));
});
});
});
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.63-beta.2",
"version": "0.1.63-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -31,8 +31,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.63-beta.2",
"@getpaseo/highlight": "0.1.63-beta.2",
"@getpaseo/expo-two-way-audio": "0.1.63-beta.1",
"@getpaseo/highlight": "0.1.63-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
+86 -73
View File
@@ -27,7 +27,7 @@ import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-nativ
import { SafeAreaProvider } from "react-native-safe-area-context";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
@@ -48,7 +48,6 @@ 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";
@@ -73,10 +72,7 @@ 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";
@@ -106,21 +102,14 @@ 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();
@@ -190,18 +179,19 @@ 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);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
@@ -277,78 +267,104 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>(() =>
getHostRuntimeStore().getEarliestOnlineHostServerId() !== null ? "online" : "starting-daemon",
);
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("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 startupWorkspaceSelection: ActiveWorkspaceSelection | null = null;
const abortController = new AbortController();
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
setStartupNavigation(null);
const loadStartupWorkspaceSelection = async (): Promise<ActiveWorkspaceSelection | null> => {
await hydrateLastNavigationWorkspaceRouteSelection();
startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection();
return startupWorkspaceSelection;
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);
}
}
};
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,
});
});
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);
});
return () => {
cancelled = true;
abortController.abort();
cancelAnyOnline?.();
};
}, [retryToken]);
@@ -357,9 +373,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
phase,
error,
retry,
startupNavigation,
}),
[error, phase, retry, startupNavigation],
[error, phase, retry],
);
return (
@@ -470,7 +485,7 @@ function AppContainer({
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<DaemonVersionMismatchCalloutSource />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
@@ -830,8 +845,6 @@ 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>
{/*
@@ -1,247 +0,0 @@
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");
});
});
@@ -1,168 +0,0 @@
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
@@ -1,157 +0,0 @@
/**
* @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");
});
});
+44 -43
View File
@@ -1,58 +1,59 @@
import React from "react";
import { Redirect, usePathname, type Href } from "expo-router";
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
type HostRuntimeBootstrapState,
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } 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 redirectRoute = resolveStartupRedirectRoute({
bootstrapState,
pathname,
storeReady,
});
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
}
useEffect(() => {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
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);
}
@@ -1,12 +0,0 @@
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} />;
}
@@ -1,7 +0,0 @@
import SettingsScreen from "@/screens/settings-screen";
const PROJECTS_VIEW = { kind: "projects" as const };
export default function SettingsProjectsIndexRoute() {
return <SettingsScreen view={PROJECTS_VIEW} />;
}
@@ -18,7 +18,7 @@ import {
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -84,7 +84,6 @@ import {
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -157,6 +156,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
ref,
) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -271,13 +271,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
],
);
const handleToolCallOpenFile = useCallback(
(filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath });
},
[handleInlinePathPress],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
tail: streamItems,
@@ -303,9 +296,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
const tightGap = SPACING[1];
const assistantBlockGap = SPACING[3];
const looseGap = SPACING[4];
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
const looseGap = theme.spacing[4]; // 16px
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
@@ -492,7 +485,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
}
@@ -507,11 +499,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
);
const renderStreamItemContent = useCallback(
@@ -891,33 +882,16 @@ function ToolCallSlot({
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCheckIcon = withUnistyles(Check);
const ThemedXIcon = withUnistyles(X);
const primaryColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const mutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const pressableStyle = ({
pressed,
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
hovered ? permissionStyles.optionButtonHovered : null,
pressed ? permissionStyles.optionButtonPressed : null,
];
interface PermissionActionButtonProps {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
isPrimary: boolean;
Icon: typeof ThemedCheckIcon;
textColor: string;
iconColor: string;
isDanger: boolean;
Icon: typeof Check;
testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void;
}
@@ -925,21 +899,37 @@ function PermissionActionButton({
action,
isRespondingAction,
isResponding,
isPrimary,
textColor,
iconColor,
isDanger,
Icon,
testID,
theme,
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText;
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent, isDanger],
);
const optionTextStyle = useMemo(
() => [permissionStyles.optionText, { color: textColor }],
[textColor],
);
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} uniProps={colorMapping} />
<Icon size={14} color={iconColor} />
<Text style={optionTextStyle}>{action.label}</Text>
</View>
)}
@@ -954,6 +944,7 @@ function PermissionRequestCard({
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const { request } = permission;
@@ -1069,6 +1060,10 @@ function PermissionRequestCard({
[handleResponse],
);
const questionTextStyle = useMemo(
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const optionsContainerStyle = useMemo(
() => [
permissionStyles.optionsContainer,
@@ -1076,6 +1071,24 @@ function PermissionRequestCard({
],
[isMobile],
);
const cardContainerStyle = useMemo(
() => [
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[theme.colors.surface1, theme.colors.border],
);
const cardTitleStyle = useMemo(
() => [permissionStyles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cardDescriptionStyle = useMemo(
() => [permissionStyles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
if (request.kind === "question") {
return (
@@ -1089,15 +1102,18 @@ function PermissionRequestCard({
const footer = (
<>
<Text testID="permission-request-question" style={permissionStyles.question}>
<Text testID="permission-request-question" style={questionTextStyle}>
How would you like to proceed?
</Text>
<View style={optionsContainerStyle}>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement")
@@ -1110,9 +1126,12 @@ function PermissionRequestCard({
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
isPrimary={isPrimary}
textColor={textColor}
iconColor={iconColor}
isDanger={isDanger}
Icon={Icon}
testID={testID}
theme={theme}
onPress={handleActionPress}
/>
);
@@ -1134,10 +1153,10 @@ function PermissionRequestCard({
}
return (
<View style={permissionStyles.container}>
<Text style={permissionStyles.title}>{title}</Text>
<View style={cardContainerStyle}>
<Text style={cardTitleStyle}>{title}</Text>
{description ? <Text style={permissionStyles.description}>{description}</Text> : null}
{description ? <Text style={cardDescriptionStyle}>{description}</Text> : null}
{planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
@@ -1283,18 +1302,14 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
color: theme.colors.foreground,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[2],
@@ -1306,7 +1321,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted,
},
optionsContainer: {
gap: theme.spacing[2],
@@ -1323,11 +1337,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
alignItems: "center",
borderWidth: theme.borderWidth[1],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
optionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
optionButtonPressed: {
opacity: 0.9,
@@ -1340,15 +1349,9 @@ const permissionStyles = StyleSheet.create((theme) => ({
optionText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
optionTextPrimary: {
color: theme.colors.foreground,
},
}));
const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary];
interface StreamItemWrapperProps {
gapBelow: number;
children: ReactNode;
@@ -55,15 +55,15 @@ vi.mock("lucide-react-native", () => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { SidebarCallout } from "./sidebar-callout";
import { CalloutCard } from "./callout-card";
type SidebarCalloutActions = React.ComponentProps<typeof SidebarCallout>["actions"];
type CalloutCardActions = React.ComponentProps<typeof CalloutCard>["actions"];
function buildSingleAction(onPress: () => void): SidebarCalloutActions {
function buildSingleAction(onPress: () => void): CalloutCardActions {
return [{ label: "Undo", onPress }];
}
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): SidebarCalloutActions {
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): CalloutCardActions {
return [
{ label: "What's new", onPress: onWhatsNew },
{ label: "Install & restart", onPress: onInstall, variant: "primary" },
@@ -72,7 +72,7 @@ function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): Sidebar
const calloutTitleIcon = <span data-testid="callout-title-icon" />;
describe("SidebarCallout", () => {
describe("CalloutCard", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -96,7 +96,7 @@ describe("SidebarCallout", () => {
it("renders title and description", () => {
act(() => {
root?.render(
<SidebarCallout title="Update available" description="v1.2.3 is ready to install." />,
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
);
});
@@ -106,7 +106,7 @@ describe("SidebarCallout", () => {
it("renders an icon next to the title", () => {
act(() => {
root?.render(<SidebarCallout title="Update available" icon={calloutTitleIcon} />);
root?.render(<CalloutCard title="Update available" icon={calloutTitleIcon} />);
});
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
@@ -116,7 +116,7 @@ describe("SidebarCallout", () => {
const onPress = vi.fn();
const actions = buildSingleAction(onPress);
act(() => {
root?.render(<SidebarCallout description="Saved." actions={actions} testID="callout" />);
root?.render(<CalloutCard description="Saved." actions={actions} testID="callout" />);
});
const button = container?.querySelector(
@@ -130,7 +130,7 @@ describe("SidebarCallout", () => {
const actions = buildTwoActions(vi.fn(), vi.fn());
act(() => {
root?.render(
<SidebarCallout
<CalloutCard
title="Update available"
description="v1 ready."
actions={actions}
@@ -149,7 +149,7 @@ describe("SidebarCallout", () => {
it("renders no action row when no actions are provided", () => {
act(() => {
root?.render(<SidebarCallout description="Copied" testID="callout" />);
root?.render(<CalloutCard description="Copied" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
@@ -158,7 +158,7 @@ describe("SidebarCallout", () => {
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
const onDismiss = vi.fn();
act(() => {
root?.render(<SidebarCallout description="Saved" onDismiss={onDismiss} testID="callout" />);
root?.render(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
});
const dismissButton = container?.querySelector(
@@ -169,7 +169,7 @@ describe("SidebarCallout", () => {
it("omits the dismiss button when onDismiss is not provided", () => {
act(() => {
root?.render(<SidebarCallout description="Saved" testID="callout" />);
root?.render(<CalloutCard description="Saved" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
@@ -3,33 +3,33 @@ import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type SidebarCalloutActionVariant = "primary" | "secondary";
export type CalloutActionVariant = "primary" | "secondary";
export interface SidebarCalloutAction {
export interface CalloutAction {
label: string;
onPress: () => void;
variant?: SidebarCalloutActionVariant;
variant?: CalloutActionVariant;
disabled?: boolean;
testID?: string;
}
export type SidebarCalloutVariant = "default" | "success" | "error";
export type CalloutVariant = "default" | "success" | "error";
export interface SidebarCalloutProps {
export interface CalloutCardProps {
title?: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
variant?: CalloutVariant;
actions?: readonly CalloutAction[];
onDismiss?: () => void;
testID?: string;
}
export function SidebarCalloutDescriptionText({ children }: { children: ReactNode }) {
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
return <Text style={styles.description}>{children}</Text>;
}
export function SidebarCallout({
export function CalloutCard({
title,
description,
icon,
@@ -37,7 +37,7 @@ export function SidebarCallout({
actions,
onDismiss,
testID,
}: SidebarCalloutProps) {
}: CalloutCardProps) {
const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null;
@@ -84,7 +84,7 @@ export function SidebarCallout({
) : null}
{hasDescription && typeof description === "string" ? (
<SidebarCalloutDescriptionText>{description}</SidebarCalloutDescriptionText>
<CalloutDescriptionText>{description}</CalloutDescriptionText>
) : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
@@ -93,7 +93,7 @@ export function SidebarCallout({
{visibleActions.length > 0 ? (
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
{visibleActions.map((action, index) => (
<SidebarCalloutActionButton
<CalloutActionButton
key={action.label}
action={action}
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
@@ -106,13 +106,7 @@ export function SidebarCallout({
);
}
function SidebarCalloutActionButton({
action,
testID,
}: {
action: SidebarCalloutAction;
testID?: string;
}) {
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
const isPrimary = action.variant === "primary";
const labelStyle = useMemo(
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
@@ -188,7 +188,6 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
}));
+26 -28
View File
@@ -7,7 +7,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import {
@@ -40,7 +40,7 @@ import {
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
@@ -92,8 +92,8 @@ type AttachmentListUpdater =
function noop() {}
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
function resolveComposerButtonIconSize(theme: Theme): number {
return isWeb ? theme.iconSize.md : theme.iconSize.lg;
}
function resolveIsComposerLocked(
@@ -585,6 +585,7 @@ interface QueuedMessageRowProps {
}
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
const { theme } = useUnistyles();
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
@@ -598,10 +599,10 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
</Text>
<View style={styles.queueActions}>
<Pressable onPress={handleEdit} style={styles.queueActionButton}>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}>
<ArrowUp size={ICON_SIZE.sm} color="white" />
<ArrowUp size={theme.iconSize.sm} color="white" />
</Pressable>
</View>
</View>
@@ -667,6 +668,7 @@ function GithubAttachmentPill({
onOpen,
onRemove,
}: GithubAttachmentPillProps) {
const { theme } = useUnistyles();
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const handleOpen = useCallback(() => {
@@ -687,9 +689,9 @@ function GithubAttachmentPill({
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
@@ -717,17 +719,18 @@ function GithubPickerOption({
item,
onToggle,
}: GithubPickerOptionProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onToggle(item);
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
),
[item.kind],
[item.kind, theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
@@ -869,6 +872,7 @@ interface ComposerVoiceModeButtonProps {
realtimeVoiceButtonStyle: (
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
theme: Theme;
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
}
@@ -907,6 +911,7 @@ function ComposerVoiceModeButton({
isConnected,
isVoiceSwitching,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? (
@@ -917,10 +922,10 @@ function ComposerVoiceModeButton({
if (isVoiceSwitching) {
return <ActivityIndicator size="small" color="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return <AudioLines size={buttonIconSize} color={color} />;
},
[buttonIconSize, isVoiceSwitching],
[buttonIconSize, isVoiceSwitching, theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -973,7 +978,8 @@ export function Composer({
inputWrapperStyle,
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const buttonIconSize = resolveComposerButtonIconSize();
const { theme } = useUnistyles();
const buttonIconSize = resolveComposerButtonIconSize(theme);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1424,6 +1430,7 @@ export function Composer({
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitchingValue}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
theme={theme}
voiceToggleKeys={voiceToggleKeys}
cancelButton={cancelButton}
/>
@@ -1440,6 +1447,7 @@ export function Composer({
isVoiceModeForAgent,
isVoiceSwitchingValue,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
],
);
@@ -1483,7 +1491,7 @@ export function Composer({
{
id: "image",
label: "Add image",
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
void handlePickImage();
},
@@ -1491,13 +1499,13 @@ export function Composer({
{
id: "github",
label: "Add issue or PR",
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage],
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md],
);
const handleToggleGithubItem = useCallback(
@@ -1852,13 +1860,3 @@ const styles = StyleSheet.create((theme: Theme) => ({
})) as unknown as Record<string, object>;
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton];
const ThemedPencil = withUnistyles(Pencil);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -0,0 +1,209 @@
/**
* @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 { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const hostsState = vi.hoisted(() => ({
value: [{ serverId: "server-1", label: "Local daemon" }],
}));
const sessionState = vi.hoisted(() => ({
value: {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
},
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => hostsState.value,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: typeof sessionState.value) => unknown) =>
selector(sessionState.value),
}));
vi.mock("@/utils/app-version", () => ({
resolveAppVersion: () => "1.2.3",
}));
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 X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source";
function Harness() {
return (
<SidebarCalloutProvider>
<DaemonVersionMismatchCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
);
}
async function renderHarness(root: Root): Promise<void> {
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
}
describe("DaemonVersionMismatchCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
hostsState.value = [{ serverId: "server-1", label: "Local daemon" }];
sessionState.value = {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
};
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("registers a daemon mismatch sidebar callout", async () => {
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).not.toBeNull();
expect(container?.textContent).toContain("Daemon version mismatch");
expect(container?.textContent).toContain("Local daemon is running v1.2.2");
expect(container?.textContent).toContain("This app is v1.2.3");
expect(container?.textContent).toContain(
"For the best experience, keep the daemon and client on the same version.",
);
});
it("does not register a callout when versions match", async () => {
sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3";
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
it("opens host settings from the callout action", async () => {
await renderHarness(root!);
const action = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-action-0"]',
) as HTMLElement | null;
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1");
});
it("persists dismissal for the current app-daemon version pair", async () => {
await renderHarness(root!);
const dismiss = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]',
) as HTMLElement | null;
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]),
);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
});
@@ -0,0 +1,79 @@
import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { resolveAppVersion } from "@/utils/app-version";
import { buildSettingsHostRoute } from "@/utils/host-routes";
interface DaemonVersionMismatch {
serverId: string;
label: string;
appVersion: string;
daemonVersion: string;
}
function useDaemonVersionMismatches(): DaemonVersionMismatch[] {
const hosts = useHosts();
const sessions = useSessionStore((state) => state.sessions);
const appVersion = resolveAppVersion();
return useMemo(() => {
if (!appVersion) {
return [];
}
return hosts
.map((entry) => ({
serverId: entry.serverId,
label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon",
appVersion,
daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null,
}))
.filter((entry): entry is DaemonVersionMismatch =>
isVersionMismatch(entry.appVersion, entry.daemonVersion),
);
}, [appVersion, hosts, sessions]);
}
function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) {
const callouts = useSidebarCallouts();
const router = useRouter();
const { appVersion, daemonVersion, label, serverId } = mismatch;
const openSettings = useStableEvent(() => {
router.navigate(buildSettingsHostRoute(serverId));
});
useEffect(() => {
return callouts.show({
id: `daemon-version-mismatch:${serverId}`,
dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`,
priority: 300,
title: "Daemon version mismatch",
description: `${label} is running ${formatVersionWithPrefix(
daemonVersion,
)}. This app is ${formatVersionWithPrefix(
appVersion,
)}. For the best experience, keep the daemon and client on the same version.`,
variant: "error",
actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }],
testID: `daemon-version-mismatch-callout-${serverId}`,
});
}, [appVersion, callouts, daemonVersion, label, openSettings, serverId]);
return null;
}
export function DaemonVersionMismatchCalloutSource() {
const mismatches = useDaemonVersionMismatches();
return (
<>
{mismatches.map((mismatch) => (
<DaemonVersionMismatchRegistration key={mismatch.serverId} mismatch={mismatch} />
))}
</>
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -365,7 +365,7 @@ function FooterIconButton({
onPress: () => void;
testID: string;
accessibilityLabel: string;
icon: typeof FolderPlus;
icon: typeof Plus;
theme: SidebarTheme;
}) {
return (
@@ -448,7 +448,7 @@ function SidebarFooter({
onPress={handleOpenProject}
testID="sidebar-add-project"
accessibilityLabel="Add project"
icon={FolderPlus}
icon={Plus}
theme={theme}
/>
</TooltipTrigger>
@@ -47,7 +47,7 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
+30 -23
View File
@@ -18,8 +18,8 @@ import {
useMemo,
forwardRef,
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
@@ -195,15 +195,18 @@ function AttachButtonIcon({
hovered,
onAttachButtonRef,
buttonIconSize,
foreground,
foregroundMuted,
}: {
hovered: boolean;
onAttachButtonRef: ((node: View | null) => void) | undefined;
buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) {
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return (
<View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}>
<ThemedPlus size={buttonIconSize} uniProps={colorMapping} />
<Plus size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />
</View>
);
}
@@ -275,20 +278,23 @@ function VoiceButtonIcon({
isDictating,
isMutedRealtime,
buttonIconSize,
foreground,
foregroundMuted,
}: {
hovered: boolean;
isDictating: boolean;
isMutedRealtime: boolean;
buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) {
if (isDictating) {
return <Square size={buttonIconSize} color="white" fill="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
return <ThemedMicOff size={buttonIconSize} uniProps={colorMapping} />;
return <MicOff size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />;
}
return <ThemedMic size={buttonIconSize} uniProps={colorMapping} />;
return <Mic size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />;
}
type ShortcutChord = NonNullable<React.ComponentProps<typeof Shortcut>["chord"]>;
@@ -1359,7 +1365,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onHeightChange,
inputWrapperStyle,
} = resolveMessageInputProps(props);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = computeInvestigationComponentId(voiceServerId, voiceAgentId);
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
@@ -1885,9 +1892,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
hovered={Boolean(hovered)}
onAttachButtonRef={onAttachButtonRef}
buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[onAttachButtonRef, buttonIconSize],
[onAttachButtonRef, buttonIconSize, theme.colors.foreground, theme.colors.foregroundMuted],
);
const renderVoiceButtonIcon = useCallback(
@@ -1897,9 +1906,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictating={isDictating}
isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)}
buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
[
isDictating,
isRealtimeVoiceForCurrentAgent,
voice?.isMuted,
buttonIconSize,
theme.colors.foreground,
theme.colors.foregroundMuted,
],
);
return (
@@ -1908,12 +1926,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
<ThemedTextInput
<TextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
uniProps={textInputPlaceholderColorMapping}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={handleInputFocus}
onBlur={handleInputBlur}
@@ -2141,14 +2159,3 @@ const styles = StyleSheet.create((theme: Theme) => ({
bottom: 0,
},
})) as unknown as Record<string, object>;
const ThemedPlus = withUnistyles(Plus);
const ThemedMic = withUnistyles(Mic);
const ThemedMicOff = withUnistyles(MicOff);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const textInputPlaceholderColorMapping = (theme: Theme) => ({
placeholderTextColor: theme.colors.surface4,
});
+58 -223
View File
@@ -4,7 +4,6 @@ import {
Image,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type LayoutChangeEvent,
StyleProp,
ViewStyle,
@@ -46,10 +45,8 @@ import {
TriangleAlertIcon,
Scissors,
MicVocal,
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
@@ -67,7 +64,6 @@ 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,
@@ -103,29 +99,6 @@ export type { InlinePathTarget } from "@/utils/inline-path";
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
function useMessageRenderTracker(
label: string,
identity: string,
watched: Record<string, unknown>,
): void {
const renderCountRef = useRef(0);
const prevRef = useRef<Record<string, unknown> | null>(null);
renderCountRef.current += 1;
const changed: string[] = [];
if (prevRef.current !== null) {
for (const key of Object.keys(watched)) {
if (!Object.is(prevRef.current[key], watched[key])) {
changed.push(key);
}
}
}
prevRef.current = { ...watched };
console.log(`[${label}]`, identity, `render #${renderCountRef.current}`, {
isFirst: renderCountRef.current === 1,
changed,
});
}
interface UserMessageProps {
message: string;
images?: UserMessageImageAttachment[];
@@ -178,26 +151,6 @@ interface MarkdownWithStableRendererProps {
}
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
const markdownStyleMapping = (theme: Theme) => ({ style: createMarkdownStyles(theme) }) as never;
const ThemedMicVocal = withUnistyles(MicVocal);
const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const mutedForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.mutedForeground,
});
const primaryForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.primaryForeground,
});
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% {
@@ -425,13 +378,7 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
useMessageRenderTracker("UserMessage", `${(message ?? "").slice(0, 16)}`, {
message,
images,
isFirstInGroup,
isLastInGroup,
disableOuterSpacing,
});
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
@@ -448,12 +395,12 @@ export const UserMessage = memo(function UserMessage({
() => [
userMessageStylesheet.container,
!resolvedDisableOuterSpacing && [
isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null,
isLastInGroup ? userMessageStylesheet.containerLastInGroup : null,
!isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null,
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
],
],
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup],
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup, theme.spacing],
);
const imagePreviewContainerStyle = useMemo(
() => [
@@ -678,6 +625,7 @@ function AssistantMarkdownImage({
workspaceRoot?: string;
serverId?: string;
}) {
const { theme } = useUnistyles();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
@@ -685,10 +633,10 @@ function AssistantMarkdownImage({
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? 16 : 0,
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
marginBottom: 0,
}),
[hasLeadingContent],
[hasLeadingContent, theme],
);
const query = useQuery({
@@ -1046,8 +994,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
opacity: 0.72,
},
secondaryLabel: {
flexShrink: 1,
minWidth: 0,
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
@@ -1069,12 +1016,6 @@ 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" }],
},
@@ -1273,6 +1214,7 @@ function AssistantMessageBlockContainer({
interface MemoizedMarkdownBlockProps {
text: string;
styles: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
@@ -1280,13 +1222,14 @@ interface MemoizedMarkdownBlockProps {
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
text,
styles,
rules,
parser,
onLinkPress,
}: MemoizedMarkdownBlockProps) {
return (
<ThemedMarkdown
uniProps={markdownStyleMapping}
<MarkdownWithStableRenderer
style={styles}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
@@ -1294,27 +1237,22 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
</MarkdownWithStableRenderer>
);
});
interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
children: ReactNode;
}
function MarkdownInheritedText({
inheritedStyles,
textStyle,
style: overrideStyle,
children,
}: MarkdownInheritedTextProps) {
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
const style = useMemo(() => [inheritedStyles, textStyle], [inheritedStyles, textStyle]);
return <Text style={style}>{children}</Text>;
}
@@ -1380,23 +1318,13 @@ export const AssistantMessage = memo(function AssistantMessage({
disableOuterSpacing,
spacing = "default",
}: AssistantMessageProps) {
useMessageRenderTracker(
"AssistantMessage",
`${serverId ?? "?"}/${(message ?? "").slice(0, 16)}`,
{
message,
onInlinePathPress,
workspaceRoot,
serverId,
client,
disableOuterSpacing,
spacing,
},
);
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
disableOuterSpacing ?? spacing !== "default",
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
@@ -1659,10 +1587,11 @@ export const AssistantMessage = memo(function AssistantMessage({
<AssistantMessageBlockContainer
key={key}
block={block}
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
marginBottom={index < keyedBlocks.length - 1 ? theme.spacing[3] : 0}
>
<MemoizedMarkdownBlock
text={block}
styles={markdownStyles}
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleLinkPress}
@@ -1712,6 +1641,7 @@ export const SpeakMessage = memo(function SpeakMessage({
timestamp: _timestamp,
disableOuterSpacing,
}: SpeakMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const containerStyle = useMemo(
() => [
@@ -1724,7 +1654,7 @@ export const SpeakMessage = memo(function SpeakMessage({
return (
<View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}>
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
<MicVocal size={14} color={theme.colors.foregroundMuted} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
</View>
<Text style={speakMessageStylesheet.text}>{message}</Text>
@@ -1982,6 +1912,7 @@ interface TodoListItemRowProps {
}
function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
const { theme: todoUnistylesTheme } = useUnistyles();
const badgeStyle = useMemo(
() => [
todoListCardStylesheet.radioBadge,
@@ -1998,9 +1929,7 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
return (
<View style={todoListCardStylesheet.itemRow}>
<View style={badgeStyle}>
{completed ? (
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
) : null}
{completed ? <Check size={12} color={todoUnistylesTheme.colors.primaryForeground} /> : null}
</View>
<Text style={textStyle}>{text}</Text>
</View>
@@ -2052,6 +1981,7 @@ export const TodoListCard = memo(function TodoListCard({
items,
disableOuterSpacing,
}: TodoListCardProps) {
const { theme: _unistylesTheme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false);
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
@@ -2096,7 +2026,6 @@ interface ExpandableBadgeProps {
isExpanded: boolean;
style?: StyleProp<ViewStyle>;
onToggle?: () => void;
onOpenFile?: () => void;
onDetailHoverChange?: (hovered: boolean) => void;
renderDetails?: () => ReactNode;
isLoading?: boolean;
@@ -2120,7 +2049,7 @@ function ExpandableBadgeSecondaryLabel({
onSecondaryLayout,
}: ExpandableBadgeSecondaryLabelProps) {
if (!secondaryLabel) {
return null;
return <View style={expandableBadgeStylesheet.spacer} />;
}
return (
<Text
@@ -2181,11 +2110,6 @@ 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({
@@ -2207,11 +2131,6 @@ function ExpandableBadgeLabelRow({
onLabelRowLayout,
onLabelLayout,
onSecondaryLayout,
showOpenFileButton,
isOpenFileHovered,
onOpenFilePress,
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
return (
<View
@@ -2231,23 +2150,6 @@ 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}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
{isWebShimmer ? (
<ExpandableBadgeWebShimmerOverlay
label={label}
@@ -2271,44 +2173,37 @@ function ExpandableBadgeLabelRow({
);
}
function renderExpandableBadgeIcon({
function resolveExpandableBadgeIconColor({
isError,
isActive,
ThemedIcon,
theme,
}: {
isError: boolean;
isActive: boolean;
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
}): ReactNode {
if (isError) {
return <ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />;
}
if (ThemedIcon) {
return (
<ThemedIcon
size={12}
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
/>
);
}
return null;
theme: ReturnType<typeof useUnistyles>["theme"];
}): string {
if (isError) return theme.colors.destructive;
if (isActive) return theme.colors.foreground;
return theme.colors.mutedForeground;
}
function renderExpandableBadgeIconSlot({
showChevron,
chevronStyle,
iconNode,
function renderExpandableBadgeIcon({
isError,
iconColor,
icon,
}: {
showChevron: boolean;
chevronStyle: StyleProp<ViewStyle>;
iconNode: ReactNode;
isError: boolean;
iconColor: string;
icon?: ComponentType<{ size?: number; color?: string }>;
}): ReactNode {
if (showChevron) {
return (
<ThemedChevronRightIcon size={14} style={chevronStyle} uniProps={foregroundColorMapping} />
);
if (isError) {
return <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />;
}
return iconNode;
if (icon) {
const IconComponent = icon;
return <IconComponent size={12} color={iconColor} />;
}
return null;
}
function computeShimmerMetrics(input: {
@@ -2420,7 +2315,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
icon,
isExpanded,
onToggle,
onOpenFile,
onDetailHoverChange,
renderDetails,
isLoading = false,
@@ -2429,10 +2323,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
disableOuterSpacing,
testID,
}: ExpandableBadgeProps) {
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);
@@ -2452,17 +2345,6 @@ 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)}`,
@@ -2657,17 +2539,14 @@ const ExpandableBadge = memo(function ExpandableBadge({
[isExpanded],
);
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
const iconSlotNode = renderExpandableBadgeIconSlot({
showChevron: isInteractive && isHovered && !isIconHovered,
chevronStyle,
iconNode,
});
const iconColor = resolveExpandableBadgeIconColor({ isError, isActive, theme });
const iconNode = renderExpandableBadgeIcon({ isError, iconColor, icon });
const pressHandlers = isInteractive
? {
onPress: onToggle,
onHoverIn: handleHoverIn,
onHoverOut: handleHoverOut,
onPressIn: handlePressIn,
onPressOut: handlePressOut,
accessibilityRole: "button" as const,
@@ -2675,12 +2554,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
: {};
return (
<View
style={containerStyle}
testID={testID}
onPointerEnter={isWeb ? handleHoverIn : undefined}
onPointerLeave={isWeb ? handleHoverOut : undefined}
>
<View style={containerStyle} testID={testID}>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
@@ -2688,13 +2562,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
style={pressableStyle}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View
style={expandableBadgeStylesheet.iconBadge}
onPointerEnter={isWeb ? handleIconHoverIn : undefined}
onPointerLeave={isWeb ? handleIconHoverOut : undefined}
>
{iconSlotNode}
</View>
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
<ExpandableBadgeLabelRow
label={label}
labelStyle={labelStyle}
@@ -2714,12 +2582,10 @@ 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 ? (
@@ -2748,7 +2614,6 @@ 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;
@@ -2767,7 +2632,6 @@ interface ToolCallProps {
disableOuterSpacing?: boolean;
onInlineDetailsHoverChange?: (hovered: boolean) => void;
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
onOpenFilePath?: (filePath: string) => void;
}
export const ToolCall = memo(function ToolCall({
@@ -2783,23 +2647,7 @@ export const ToolCall = memo(function ToolCall({
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
}: ToolCallProps) {
useMessageRenderTracker("ToolCall", `${toolName}/${status}`, {
toolName,
args,
result,
error,
status,
detail,
cwd,
metadata,
isLastInSequence,
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
});
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -2857,17 +2705,6 @@ 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({
@@ -2950,7 +2787,6 @@ 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"}
@@ -2972,6 +2808,5 @@ 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, Trash2 } from "lucide-react-native";
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
@@ -10,19 +10,14 @@ 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;
@@ -31,173 +26,23 @@ interface ProviderDiagnosticSheetProps {
serverId: string;
}
function ModelRow({ model }: { model: AgentModelDefinition }) {
function ModelRow({ model, isFirst }: { model: AgentModelDefinition; isFirst: boolean }) {
const rowStyle = useMemo(
() => [sheetStyles.modelRow, !isFirst && sheetStyles.modelRowBorder],
[isFirst],
);
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>
<View style={rowStyle}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</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;
@@ -228,7 +73,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>
);
}
@@ -265,6 +110,7 @@ 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]);
@@ -351,27 +197,10 @@ 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.emptyRow}>
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models…</Text>
</View>
@@ -379,7 +208,7 @@ export function ProviderDiagnosticSheet({
}
if (models.length === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyRow}>
<View style={sheetStyles.emptyState}>
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
</View>
@@ -387,21 +216,21 @@ export function ProviderDiagnosticSheet({
}
if (models.length === 0) {
return (
<View style={sheetStyles.emptyRow}>
<Text style={sheetStyles.mutedText}>No models detected</Text>
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected.</Text>
</View>
);
}
if (filteredModels.length === 0) {
return (
<View style={sheetStyles.emptyRow}>
<View style={sheetStyles.emptyState}>
<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) => (
<ModelRow key={model.id} model={model} />
return filteredModels.map((model: AgentModelDefinition, index) => (
<ModelRow key={model.id} model={model} isFirst={index === 0} />
));
}
@@ -414,26 +243,33 @@ export function ProviderDiagnosticSheet({
scrollable={false}
headerActions={headerActions}
>
<SettingsSection title="Diagnostic">
<View style={settingsStyles.card}>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
<DiagnosticCodeBlock
loading={loading}
diagnostic={diagnostic}
foregroundMutedColor={theme.colors.foregroundMuted}
/>
</View>
</SettingsSection>
<CustomModelsSection provider={provider} serverId={serverId} refresh={refresh} />
</View>
<View style={sheetStyles.modelsSection}>
<View style={sheetStyles.modelsHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Models</Text>
{modelsTrailing}
<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>
</View>
<View style={MODELS_CARD_STYLE}>
<View style={INLINE_ROW_STYLE}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{models.length > 0 ? (
<View style={sheetStyles.searchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
value={query}
onChangeText={setQuery}
@@ -445,37 +281,33 @@ export function ProviderDiagnosticSheet({
style={DIAGNOSTIC_SEARCH_INPUT_STYLE}
/>
</View>
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
</View>
) : null}
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
</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,
@@ -489,24 +321,20 @@ const sheetStyles = StyleSheet.create((theme) => ({
disabled: {
opacity: 0.5,
},
inlineRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
inlineInput: {
flex: 1,
minWidth: 0,
paddingVertical: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
codeBlock: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
overflow: "hidden",
maxHeight: 180,
},
codeScroll: {
maxHeight: 200,
maxHeight: 180,
},
codeContent: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
},
codeText: {
fontFamily: Fonts.mono,
@@ -516,7 +344,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -524,46 +352,70 @@ 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],
},
flexCard: {
flex: 1,
minHeight: 0,
},
modelsTrailing: {
modelsHeaderMeta: {
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: 0,
paddingBottom: theme.spacing[2],
},
emptyRow: {
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
modelRow: {
paddingVertical: theme.spacing[3],
},
modelRowBorder: {
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.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];
const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.searchInput, isWeb && { outlineStyle: "none" }];
@@ -9,7 +9,6 @@ import {
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query";
@@ -25,8 +24,7 @@ import {
} from "react";
import { router, usePathname, type Href } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
@@ -41,7 +39,6 @@ import {
FolderGit2,
GitPullRequest,
Globe,
Settings,
SquareTerminal,
Monitor,
MoreVertical,
@@ -56,7 +53,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
@@ -87,8 +83,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -128,47 +123,17 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
function getPrIconUniMapping(state: PrHint["state"]) {
function getWorkspacePrIconColor(
theme: ReturnType<typeof useUnistyles>["theme"],
state: PrHint["state"],
) {
switch (state) {
case "merged":
return purpleColorMapping;
return theme.colors.palette.purple[500];
case "open":
return greenColorMapping;
return theme.colors.palette.green[500];
case "closed":
return redColorMapping;
return theme.colors.palette.red[500];
}
}
@@ -256,7 +221,10 @@ function useSidebarWorkspaceEntry(
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
@@ -273,8 +241,10 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const prBadgeTextStyle = useMemo(
() => [prBadgeStyles.text, { color: activeColor }],
[activeColor],
);
return (
<Pressable
@@ -288,11 +258,11 @@ export function PrBadge({ hint }: { hint: PrHint }) {
style={prBadgePressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
<ExternalLink size={12} color={activeColor} />
) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
<GitPullRequest size={12} color={iconColor} />
)}
<Text style={textStyle} numberOfLines={1}>
<Text style={prBadgeTextStyle} numberOfLines={1}>
#{hint.number}
</Text>
</Pressable>
@@ -330,16 +300,15 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
},
textHovered: {
color: theme.colors.foreground,
},
}));
const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered];
function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null {
const { theme } = useUnistyles();
const color = theme.colors.palette.red[500];
const textStyle = useMemo(() => [checksBadgeStyles.text, { color }], [color]);
if (!checks || checks.length === 0) return null;
const failed = checks.filter((c) => c.status === "failure").length;
@@ -349,8 +318,8 @@ function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | n
return (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{label}</Text>
<GitHubIcon size={10} color={color} />
<Text style={textStyle}>{label}</Text>
</View>
);
}
@@ -365,7 +334,6 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.palette.red[500],
},
}));
@@ -378,12 +346,13 @@ function WorkspaceStatusIndicator({
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean;
}) {
const { theme } = useUnistyles();
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View>
);
}
@@ -391,7 +360,14 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View>
);
}
@@ -399,18 +375,18 @@ function WorkspaceStatusIndicator({
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
let KindIcon: typeof Monitor | typeof FolderGit2 | null;
if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
else KindIcon = null;
if (!KindIcon) return null;
const dotColorStyle = getStatusDotColorStyle(bucket);
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
@@ -421,10 +397,11 @@ function WorkspaceStatusIndicator({
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<KindIcon size={14} color={theme.colors.foregroundMuted} />
{dotColor ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
dotColor={dotColor}
borderColor={theme.colors.surface0}
size={statusDotSize}
offset={statusDotOffset}
/>
@@ -434,26 +411,29 @@ function WorkspaceStatusIndicator({
}
function StatusDotOverlay({
dotColorStyle,
dotColor,
borderColor,
size,
offset,
}: {
dotColorStyle: ViewStyle;
dotColor: string;
borderColor: string;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
backgroundColor: dotColor,
borderColor,
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColorStyle, size, offset],
[dotColor, borderColor, size, offset],
);
return <View style={overlayStyle} />;
}
@@ -473,6 +453,7 @@ function ProjectLeadingVisual({
showChevron?: boolean;
isArchiving?: boolean;
}) {
const { theme } = useUnistyles();
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace;
@@ -505,6 +486,7 @@ function ProjectLeadingVisual({
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
theme={theme}
/>
);
}
@@ -519,6 +501,7 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup,
onRemoveProject,
removeProjectStatus,
theme,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -529,6 +512,7 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup: () => void;
onRemoveProject?: () => void;
removeProjectStatus: "idle" | "pending" | "success";
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
return (
@@ -549,6 +533,7 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
theme={theme}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -558,34 +543,30 @@ function ProjectRowTrailingActions({
);
}
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function ProjectKebabMenu({
projectKey,
theme,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
}) {
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
const removeProjectLeadingIcon = useMemo(
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -595,21 +576,12 @@ function ProjectKebabMenu({
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${projectKey}`}
>
{renderKebabTriggerIcon}
{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={trash2LeadingIcon}
leading={removeProjectLeadingIcon}
status={removeProjectStatus}
pendingLabel="Removing..."
onSelect={onRemoveProject}
@@ -630,6 +602,7 @@ function WorkspaceRowRightGroup({
isCreating,
showShortcutBadge,
shortcutNumber,
theme,
archiveLabel,
archiveStatus,
archivePendingLabel,
@@ -646,6 +619,7 @@ function WorkspaceRowRightGroup({
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
theme: ReturnType<typeof useUnistyles>["theme"];
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
@@ -660,9 +634,9 @@ function WorkspaceRowRightGroup({
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{hasRunningService ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
<Globe size={12} color={theme.colors.palette.blue[500]} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
<SquareTerminal size={12} color={theme.colors.palette.blue[500]} />
)}
</View>
) : null}
@@ -670,6 +644,7 @@ function WorkspaceRowRightGroup({
{showKebab && onArchive ? (
<WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey}
theme={theme}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onArchive={onArchive}
@@ -694,8 +669,21 @@ function WorkspaceRowRightGroup({
);
}
function WorkspaceKebabTriggerIcon({
hovered,
foreground,
foregroundMuted,
}: {
hovered: boolean;
foreground: string;
foregroundMuted: string;
}) {
return <MoreVertical size={14} color={hovered ? foreground : foregroundMuted} />;
}
function WorkspaceKebabMenu({
workspaceKey,
theme,
onCopyPath,
onCopyBranchName,
onArchive,
@@ -705,6 +693,7 @@ function WorkspaceKebabMenu({
archiveShortcutKeys,
}: {
workspaceKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onArchive: () => void;
@@ -713,10 +702,28 @@ function WorkspaceKebabMenu({
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const copyLeadingIcon = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const archiveLeadingIcon = useMemo(
() => <Archive size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<WorkspaceKebabTriggerIcon
hovered={Boolean(hovered)}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -726,7 +733,7 @@ function WorkspaceKebabMenu({
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
@@ -786,17 +793,19 @@ function ProjectLeadingVisualStatus({
isArchiving,
shouldShowSyncedLoader,
activeWorkspace,
theme,
}: {
iconDataUri: string | null;
placeholderInitial: string;
isArchiving: boolean;
shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View>
);
}
@@ -804,7 +813,14 @@ function ProjectLeadingVisualStatus({
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View>
);
}
@@ -812,12 +828,16 @@ function ProjectLeadingVisualStatus({
if (activeWorkspace.statusBucket === "needs_input") {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket);
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
@@ -829,9 +849,10 @@ function ProjectLeadingVisualStatus({
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
{dotColorStyle ? (
{dotColor ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
dotColor={dotColor}
borderColor={theme.colors.surface0}
size={statusDotSize}
offset={statusDotOffset}
/>
@@ -865,6 +886,7 @@ function NewWorktreeButton({
testID: string;
showShortcutHint?: boolean;
}) {
const { theme } = useUnistyles();
const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback(
@@ -898,12 +920,12 @@ function NewWorktreeButton({
>
{({ hovered, pressed }) =>
loading ? (
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : (
<ThemedFolderPlus
<FolderPlus
size={15}
uniProps={
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)
@@ -1163,6 +1185,7 @@ function ProjectHeaderRow({
removeProjectStatus = "idle",
dragHandleProps,
}: ProjectHeaderRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const handleBeginWorkspaceSetup = useCallback(() => {
@@ -1232,6 +1255,7 @@ function ProjectHeaderRow({
onBeginWorkspaceSetup={handleBeginWorkspaceSetup}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
theme={theme}
/>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
@@ -1307,6 +1331,7 @@ function WorkspaceRowInner({
onCopyPath,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
@@ -1402,6 +1427,7 @@ function WorkspaceRowInner({
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
theme={theme}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
@@ -2866,35 +2892,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
lineHeight: 14,
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,
},
statusDotFailed: {
backgroundColor: theme.colors.palette.red[500],
borderColor: theme.colors.surface0,
},
statusDotRunning: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.surface0,
},
statusDotAttention: {
backgroundColor: theme.colors.palette.green[500],
borderColor: theme.colors.surface0,
},
}));
function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null {
switch (bucket) {
case "needs_input":
return styles.statusDotNeedsInput;
case "failed":
return styles.statusDotFailed;
case "running":
return styles.statusDotRunning;
case "attention":
return styles.statusDotAttention;
case "done":
return null;
}
}
@@ -92,36 +92,6 @@ function isScrollContainerOverscrolledPastBottom(
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
}
const VIEWPORT_LOG_PREFIX = "[WebStreamViewport]";
function vlog(agentId: string, event: string, details: Record<string, unknown> = {}): void {
console.log(VIEWPORT_LOG_PREFIX, agentId, event, details);
}
function useRenderTracker(
label: string,
agentId: string,
watched: Record<string, unknown>,
): number {
const renderCountRef = useRef(0);
const prevRef = useRef<Record<string, unknown> | null>(null);
renderCountRef.current += 1;
const changed: string[] = [];
if (prevRef.current !== null) {
for (const key of Object.keys(watched)) {
if (!Object.is(prevRef.current[key], watched[key])) {
changed.push(key);
}
}
}
prevRef.current = { ...watched };
console.log(VIEWPORT_LOG_PREFIX, agentId, `${label} render #${renderCountRef.current}`, {
isFirst: renderCountRef.current === 1,
changed,
});
return renderCountRef.current;
}
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments,
@@ -135,26 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
useRenderTracker("WebStreamViewport", props.agentId, {
agentId: props.agentId,
segments,
"segments.historyMounted": segments.historyMounted,
"segments.historyVirtualized": segments.historyVirtualized,
"segments.liveHead": segments.liveHead,
boundary,
renderers,
"renderers.renderHistoryMountedRow": renderers.renderHistoryMountedRow,
"renderers.renderHistoryVirtualizedRow": renderers.renderHistoryVirtualizedRow,
"renderers.renderLiveHeadRow": renderers.renderLiveHeadRow,
"renderers.renderLiveAuxiliary": renderers.renderLiveAuxiliary,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
scrollEnabled,
isMobileBreakpoint,
});
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
@@ -214,7 +164,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overscan: 8,
});
useEffect(() => {
vlog(props.agentId, "effect:virtualizer-adjust setup fired", {});
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
@@ -421,7 +370,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]);
useLayoutEffect(() => {
vlog(props.agentId, "effect:activation-reset fired", { activationKey, isActivationReady });
if (!isActivationReady) {
return;
}
@@ -443,12 +391,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.clearTimeout(timeout);
};
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom, props.agentId]);
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
useEffect(() => {
vlog(props.agentId, "effect:stick-on-segments-change fired", {
followOutput: followOutputRef.current,
});
if (!followOutputRef.current) {
return;
}
@@ -458,28 +403,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
segments.historyMounted,
segments.historyVirtualized,
segments.liveHead,
props.agentId,
]);
useEffect(() => {
vlog(props.agentId, "effect:stick-on-virtual-total fired", {
followOutput: followOutputRef.current,
shouldUseVirtualizer,
virtualTotalSize,
});
if (!followOutputRef.current || !shouldUseVirtualizer) {
return;
}
scheduleStickToBottom();
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize, props.agentId]);
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]);
useEffect(() => {
vlog(props.agentId, "effect:metrics-on-segments-change fired", {
mounted: segments.historyMounted.length,
virtualized: segments.historyVirtualized.length,
liveHead: segments.liveHead.length,
virtualTotalSize,
});
updateScrollMetrics();
}, [
segments.historyMounted.length,
@@ -487,11 +420,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
segments.liveHead.length,
updateScrollMetrics,
virtualTotalSize,
props.agentId,
]);
useEffect(() => {
vlog(props.agentId, "effect:resize-observer setup fired", {});
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
if (!scrollContainer || typeof ResizeObserver === "undefined") {
@@ -525,7 +456,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
vlog(props.agentId, "effect:scroll-listeners setup fired", {});
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
@@ -674,7 +604,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId]);
useEffect(() => {
vlog(props.agentId, "effect:viewportRef-handle setup fired", {});
const handle: StreamViewportHandle = {
scrollToBottom: () => {
setFollowOutput(true);
@@ -713,9 +642,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef,
]);
const contentContainerStyle = useMemo((): CSSProperties => {
vlog(props.agentId, "memo:contentContainerStyle rebuilt", { isMobileBreakpoint });
return {
const contentContainerStyle = useMemo(
(): CSSProperties => ({
display: "flex",
flexDirection: "column",
minHeight: "100%",
@@ -724,26 +652,27 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
paddingLeft: isMobileBreakpoint ? 8 : 16,
paddingRight: isMobileBreakpoint ? 8 : 16,
boxSizing: "border-box",
};
}, [isMobileBreakpoint, props.agentId]);
const scrollContainerStyle = useMemo((): CSSProperties => {
vlog(props.agentId, "memo:scrollContainerStyle rebuilt", { scrollEnabled });
return {
}),
[isMobileBreakpoint],
);
const scrollContainerStyle = useMemo(
(): CSSProperties => ({
flex: 1,
minHeight: 0,
overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain",
};
}, [scrollEnabled, props.agentId]);
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
vlog(props.agentId, "memo:virtualRowsContainerStyle rebuilt", { virtualTotalSize });
return {
}),
[scrollEnabled],
);
const virtualRowsContainerStyle = useMemo(
(): CSSProperties => ({
position: "relative",
width: "100%",
height: virtualTotalSize,
};
}, [virtualTotalSize, props.agentId]);
}),
[virtualTotalSize],
);
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: "absolute",
@@ -756,41 +685,29 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[],
);
const mountedHistoryRows = useMemo(() => {
vlog(props.agentId, "memo:mountedHistoryRows rebuilt", {
count: segments.historyMounted.length,
});
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
));
}, [renderHistoryMountedRow, segments.historyMounted, props.agentId]);
const liveHeadRows = useMemo(() => {
vlog(props.agentId, "memo:liveHeadRows rebuilt", { count: segments.liveHead.length });
return segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
}, [renderLiveHeadRow, segments.liveHead, props.agentId]);
const liveAuxiliary = useMemo(() => {
vlog(props.agentId, "memo:liveAuxiliary rebuilt", {});
return renderLiveAuxiliary();
}, [renderLiveAuxiliary, props.agentId]);
const mountedHistoryRows = useMemo(
() =>
segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
)),
[renderHistoryMountedRow, segments.historyMounted],
);
const liveHeadRows = useMemo(
() =>
segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
)),
[renderLiveHeadRow, segments.liveHead],
);
const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
!boundary.hasLiveHead &&
!liveAuxiliary;
vlog(props.agentId, "render summary", {
virtualRowsRendered: virtualRows.length,
mountedHistoryRowsRendered: segments.historyMounted.length,
liveHeadRowsRendered: segments.liveHead.length,
shouldUseVirtualizer,
virtualTotalSize,
shouldRenderEmpty,
});
return (
<>
<div
+16 -21
View File
@@ -164,10 +164,7 @@ export function TerminalPane({
const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
// 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 lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
@@ -213,7 +210,7 @@ export function TerminalPane({
useEffect(() => {
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
lastSentTerminalSizeRef.current = null;
lastReportedSizeRef.current = null;
requestTerminalReflow();
}
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
@@ -299,8 +296,7 @@ export function TerminalPane({
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
useEffect(() => {
measuredTerminalSizeRef.current = null;
lastSentTerminalSizeRef.current = null;
lastReportedSizeRef.current = null;
}, [scopeKey]);
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
@@ -320,7 +316,7 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
getPreferredSize: () => lastReportedSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
@@ -536,25 +532,24 @@ export function TerminalPane({
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
if (
!client ||
!terminalId ||
!isPaneFocused ||
!isWorkspaceFocused ||
!isAppVisible ||
rows <= 0 ||
cols <= 0
) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) {
const previous = lastReportedSizeRef.current;
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
return;
}
const previousSent = lastSentTerminalSizeRef.current;
if (
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
) {
return;
}
lastSentTerminalSizeRef.current = nextSize;
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
@@ -382,6 +382,15 @@ interface SearchDetail {
function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[] {
const out: ReactNode[] = [];
if (detail.query) {
out.push(
<View key="search-query" style={styles.section}>
<Text selectable style={styles.scrollText}>
{detail.query}
</Text>
</View>,
);
}
if (detail.content) {
out.push(
<View key="search-content" style={styles.section}>
-121
View File
@@ -1,121 +0,0 @@
import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "lucide-react-native";
import { type ReactNode, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
export interface AlertProps {
title?: string;
description?: ReactNode;
variant?: AlertVariant;
icon?: ReactNode;
children?: ReactNode;
testID?: string;
}
const VARIANT_ICON: Record<Exclude<AlertVariant, "default">, LucideIcon> = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
};
export function Alert({
title,
description,
variant = "default",
icon,
children,
testID,
}: AlertProps) {
const { theme } = useUnistyles();
const accentColor = resolveAccentColor(variant, theme);
const containerStyle = useMemo(
() => [styles.container, accentColor ? { borderColor: accentColor } : null],
[accentColor],
);
const titleStyle = useMemo(
() => [styles.title, accentColor ? { color: accentColor } : null],
[accentColor],
);
const resolvedIcon = useMemo(() => {
if (icon !== undefined) return icon;
if (variant === "default") return null;
const Icon = VARIANT_ICON[variant];
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
}, [icon, variant, theme, accentColor]);
const hasDescription = description != null && description !== "";
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
<View style={styles.body}>
{title ? <Text style={titleStyle}>{title}</Text> : null}
{hasDescription && typeof description === "string" ? (
<Text style={styles.description}>{description}</Text>
) : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
) : null}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
</View>
);
}
function resolveAccentColor(
variant: AlertVariant,
theme: ReturnType<typeof useUnistyles>["theme"],
): string | null {
if (variant === "info") return theme.colors.palette.blue[300];
if (variant === "success") return theme.colors.palette.green[400];
if (variant === "warning") return theme.colors.palette.amber[500];
if (variant === "error") return theme.colors.destructive;
return null;
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: "transparent",
borderRadius: theme.borderRadius.xl,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
iconSlot: {
paddingTop: 2,
},
body: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
}));
+12 -32
View File
@@ -6,7 +6,7 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
@@ -104,7 +104,6 @@ export function Button({
style,
textStyle,
disabled,
loading = false,
accessibilityRole,
...props
}: PropsWithChildren<
@@ -114,12 +113,10 @@ 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") {
@@ -153,10 +150,10 @@ export function Button({
sizeStyle,
variantStyle,
pressed ? styles.pressed : null,
isDisabled ? styles.disabled : null,
disabled ? styles.disabled : null,
style,
],
[sizeStyle, variantStyle, isDisabled, style],
[sizeStyle, variantStyle, disabled, style],
);
const resolvedTextStyle = useMemo(
@@ -171,30 +168,7 @@ 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
@@ -202,7 +176,14 @@ export function Button({
return <View>{leftIcon}</View>;
}
const color = resolveIconColor();
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 iconSize = ICON_SIZE[size];
// Render function
@@ -227,8 +208,7 @@ export function Button({
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
accessibilityState={accessibilityState}
disabled={isDisabled}
disabled={disabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
@@ -1,163 +0,0 @@
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
@@ -1,128 +0,0 @@
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],
},
}));
@@ -8,20 +8,11 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import {
Dimensions,
Platform,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { Dimensions, Platform, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal";
@@ -216,6 +207,7 @@ function WorkspaceHoverCardContent({
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
}): ReactElement | null {
const { theme } = useUnistyles();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -305,7 +297,7 @@ function WorkspaceHoverCardContent({
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} theme={theme} />
</>
) : null}
</Animated.View>
@@ -314,49 +306,49 @@ function WorkspaceHoverCardContent({
);
}
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ChecksSummaryContent({
checks,
theme,
hovered,
}: {
checks: NonNullable<PrHint["checks"]>;
theme: ReturnType<typeof useUnistyles>["theme"];
hovered: boolean;
}) {
const failed = checks.filter((c) => c.status === "failure").length;
const pending = checks.filter((c) => c.status === "pending").length;
let badgeColor: string;
let badgeLabel: string;
let dotStyle: StyleProp<ViewStyle>;
let statusTextStyle: StyleProp<TextStyle>;
if (failed > 0) {
badgeColor = theme.colors.palette.red[500];
badgeLabel = `${failed} failed`;
dotStyle = styles.checksDotFailed;
statusTextStyle = styles.checksStatusTextFailed;
} else if (pending > 0) {
badgeColor = theme.colors.palette.amber[500];
badgeLabel = `${pending} running`;
dotStyle = styles.checksDotPending;
statusTextStyle = styles.checksStatusTextPending;
} else {
badgeColor = theme.colors.palette.green[500];
badgeLabel = `${checks.length} passed`;
dotStyle = styles.checksDotPassed;
statusTextStyle = styles.checksStatusTextPassed;
}
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
const iconColor = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const labelStyle = useMemo(
() => [styles.checksSummaryLabel, hovered && styles.checksSummaryLabelHovered],
[hovered],
);
const dotStyle = useMemo(() => [styles.checksDot, { backgroundColor: badgeColor }], [badgeColor]);
const statusTextStyle = useMemo(
() => [styles.checksStatusText, { color: badgeColor }],
[badgeColor],
);
return (
<>
{hovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
<ExternalLink size={12} color={iconColor} />
) : (
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
<GitHubIcon size={12} color={iconColor} />
)}
<Text style={labelStyle}>Checks</Text>
<View style={styles.checksSummaryCounts}>
@@ -370,32 +362,38 @@ function ChecksSummaryContent({
function ChecksSummaryPressable({
checks,
url,
theme,
}: {
checks: NonNullable<PrHint["checks"]>;
url: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const handlePress = useCallback(() => {
void openExternalUrl(`${url}/checks`);
}, [url]);
const pressableStyle = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => [
styles.checksSummaryRow,
Boolean(hovered) && styles.listRowHovered,
],
[],
);
const renderChildren = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
<ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
<ChecksSummaryContent checks={checks} theme={theme} hovered={Boolean(hovered)} />
),
[checks],
[checks, theme],
);
return (
<Pressable style={checksSummaryPressableStyle} onPress={handlePress}>
<Pressable style={pressableStyle} onPress={handlePress}>
{renderChildren}
</Pressable>
);
}
function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; hovered?: boolean }) {
return [styles.checksSummaryRow, hovered && styles.listRowHovered];
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
@@ -469,42 +467,13 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
justifyContent: "flex-end",
},
checksDotFailed: {
checksDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.red[500],
},
checksDotPending: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.amber[500],
},
checksDotPassed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.green[500],
},
checksStatusTextFailed: {
checksStatusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
checksStatusTextPending: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.amber[500],
},
checksStatusTextPassed: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[500],
},
}));
const checksSummaryLabelHoveredCombined = [
styles.checksSummaryLabel,
styles.checksSummaryLabelHovered,
];
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
@@ -140,6 +140,7 @@ function buildCreateAgentOptions({
}
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
@@ -348,6 +349,10 @@ export function WorkspaceSetupDialog() {
addImagesRef.current = addImages;
}, []);
const composerInputWrapperStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const statusControlsWithDisabled = useMemo(
() =>
@@ -410,7 +415,7 @@ export function WorkspaceSetupDialog() {
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
inputWrapperStyle={composerInputWrapperStyle}
onAddImages={handleAddImagesCallback}
/>
</View>
@@ -458,7 +463,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
lineHeight: 20,
},
composerInputWrapper: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,279 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
const activeSelection = vi.hoisted(() => ({
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
serverId: string;
workspaceId: string;
} | null,
}));
const activeWorkspace = vi.hoisted(() => ({
value: {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
} as Record<string, unknown> | null,
}));
const client = vi.hoisted(() => ({
readProjectConfig: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useNavigationActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceFields: (
serverId: string | null,
workspaceId: string | null,
project: (workspace: Record<string, unknown>) => unknown,
) => {
if (
!activeWorkspace.value ||
serverId !== activeSelection.value?.serverId ||
workspaceId !== activeWorkspace.value.id
) {
return null;
}
return project(activeWorkspace.value);
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
}));
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 X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
function readOk(config: Record<string, unknown>) {
return {
ok: true,
config,
revision: { exists: true, mtimeMs: 1, size: 2 },
};
}
function readError() {
return {
ok: false,
error: { code: "project_not_found", message: "Project not found" },
};
}
function Harness({ queryClient }: { queryClient: QueryClient }) {
return (
<QueryClientProvider client={queryClient}>
<SidebarCalloutProvider>
<WorktreeSetupCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
</QueryClientProvider>
);
}
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
await act(async () => {
root.render(<Harness queryClient={queryClient} />);
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
async function findByTestId(testID: string): Promise<HTMLElement | null> {
let element: HTMLElement | null = null;
for (let index = 0; index < 10 && !element; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
}
return element;
}
describe("WorktreeSetupCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
};
client.readProjectConfig.mockReset();
client.readProjectConfig.mockResolvedValue(readOk({}));
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
}
queryClient?.clear();
queryClient = null;
root = null;
container?.remove();
container = null;
});
it("registers a callout for an active git workspace with missing setup", async () => {
await renderHarness(root!, queryClient!);
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
expect(container?.textContent).toContain("Set up worktree scripts");
expect(container?.textContent).toContain("Open project settings");
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
});
it("does not register a callout for a non-git workspace", async () => {
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "local",
projectRootPath: "/repo/project-1",
};
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when setup is present", async () => {
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("does not register a callout without an active workspace", async () => {
activeSelection.value = null;
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when reading paseo.json fails", async () => {
client.readProjectConfig.mockResolvedValue(readError());
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("opens project settings from the callout action", async () => {
await renderHarness(root!, queryClient!);
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
});
it("persists dismissal for the project", async () => {
await renderHarness(root!, queryClient!);
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["worktree-setup-missing:project-1"]),
);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
});
@@ -1,101 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { useRouter } from "expo-router";
import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorkspaceDescriptor,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
export function WorktreeSetupCalloutSource() {
const selection = useNavigationActiveWorkspaceSelection();
const activeProject = useWorkspaceFields(
selection?.serverId ?? null,
selection?.workspaceId ?? null,
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
);
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
const callouts = useSidebarCallouts();
const router = useRouter();
const openProjectSettings = useStableEvent(() => {
if (!activeProject) {
return;
}
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
});
const readQuery = useQuery({
queryKey: ["project-config", activeProject?.serverId ?? "", activeProject?.repoRoot ?? ""],
queryFn: () => {
if (!client || !activeProject) {
throw new Error("Project config query requires an active git workspace");
}
return client.readProjectConfig(activeProject.repoRoot);
},
enabled: Boolean(client && activeProject),
retry: false,
});
const shouldShow =
activeProject !== null &&
readQuery.data?.ok === true &&
!hasSetupCommands(readQuery.data.config ?? {});
useEffect(() => {
if (!shouldShow || !activeProject) {
return;
}
return callouts.show({
id: `worktree-setup-missing:${activeProject.projectKey}`,
dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actions: [
{ label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
],
testID: `worktree-setup-callout-${activeProject.projectKey}`,
});
}, [activeProject, callouts, openProjectSettings, shouldShow]);
return null;
}
@@ -9,20 +9,22 @@ import {
useState,
} from "react";
import {
SidebarCallout,
type SidebarCalloutAction,
type SidebarCalloutProps,
type SidebarCalloutVariant,
} from "@/components/sidebar-callout";
type CalloutAction,
CalloutCard,
type CalloutCardProps,
type CalloutVariant,
} from "@/components/callout-card";
import { useStableEvent } from "@/hooks/use-stable-event";
export type SidebarCalloutAction = CalloutAction;
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
variant?: CalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
@@ -216,7 +218,7 @@ export function SidebarCalloutViewport() {
return null;
}
const cardProps: SidebarCalloutProps = {
const cardProps: CalloutCardProps = {
title: activeCallout.title,
description: activeCallout.description,
icon: activeCallout.icon,
@@ -227,5 +229,5 @@ export function SidebarCalloutViewport() {
testID: activeCallout.testID,
};
return <SidebarCallout {...cardProps} />;
return <CalloutCard {...cardProps} />;
}
@@ -1,300 +0,0 @@
/**
* @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,36 +1,121 @@
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
import { 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, Copy, FileText, Activity } from "lucide-react-native";
import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useAppSettings } from "@/hooks/use-settings";
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";
type DesktopDaemonSettings = DesktopSettings["daemon"];
function getDaemonManagementButtonLabel(isUpdating: boolean, isPaused: boolean): string {
if (isUpdating) return isPaused ? "Resuming..." : "Pausing...";
return isPaused ? "Resume" : "Pause";
}
function useDaemonManagementToggle(args: {
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;
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
daemonActionLabel: string;
setStatus: (status: DesktopDaemonStatus) => void;
refetch: () => void;
}) {
const { daemonStatus, settings, updateSettings, setStatus, refetch } = args;
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 };
}
function useDaemonManagementToggle(args: {
daemonStatus: DesktopDaemonStatus | null;
settings: { manageBuiltInDaemon: boolean };
updateSettings: (next: { manageBuiltInDaemon: boolean }) => Promise<unknown>;
setStatus: (status: DesktopDaemonStatus) => void;
setStatusMessage: (message: string | null) => void;
refetch: () => void;
}) {
const { daemonStatus, settings, updateSettings, setStatus, setStatusMessage, refetch } = args;
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const handleToggleDaemonManagement = useCallback(() => {
@@ -40,7 +125,12 @@ 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.");
@@ -65,6 +155,7 @@ function useDaemonManagementToggle(args: {
}
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
const stopPromise =
daemonStatus?.status === "running"
@@ -80,6 +171,7 @@ function useDaemonManagementToggle(args: {
})
.then(() => {
refetch();
setStatusMessage("Built-in daemon paused and stopped.");
return;
})
.catch((error) => {
@@ -100,6 +192,7 @@ function useDaemonManagementToggle(args: {
isUpdatingDaemonManagement,
refetch,
setStatus,
setStatusMessage,
settings.manageBuiltInDaemon,
updateSettings,
]);
@@ -107,28 +200,6 @@ 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);
@@ -162,6 +233,10 @@ function useDaemonCliStatusModal() {
});
}, [cliStatusOutput]);
const handleRunCliStatus = useCallback(() => {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
return {
@@ -169,7 +244,7 @@ function useDaemonCliStatusModal() {
isCliStatusModalOpen,
isLoadingCliStatus,
handleCopyCliStatus,
handleOpenCliStatus,
handleRunCliStatus,
handleCloseCliStatusModal,
};
}
@@ -222,7 +297,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>
@@ -273,14 +348,20 @@ interface DaemonInfoCardProps {
daemonStatusStateText: string;
daemonStatusDetailText: string;
isDaemonManagementPaused: boolean;
copyIcon: ReactElement;
fileTextIcon: ReactElement;
activityIcon: ReactElement;
playIcon: React.ReactElement;
pauseIcon: React.ReactElement;
rotateIcon: React.ReactElement;
copyIcon: React.ReactElement;
fileTextIcon: React.ReactElement;
activityIcon: React.ReactElement;
handleToggleDaemonManagement: () => void;
isUpdatingDaemonManagement: boolean;
keepRunningAfterQuit: boolean;
handleToggleKeepRunningAfterQuit: () => void;
isUpdatingKeepRunningAfterQuit: boolean;
daemonActionLabel: string;
daemonActionMessage: string;
statusMessage: string | null;
handleUpdateLocalDaemon: () => void;
isRestartingDaemon: boolean;
daemonStatus: DesktopDaemonStatus | null;
daemonLogs: { logPath?: string } | null;
handleCopyLogPath: () => void;
handleOpenLogs: () => void;
@@ -293,14 +374,20 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
daemonStatusStateText,
daemonStatusDetailText,
isDaemonManagementPaused,
playIcon,
pauseIcon,
rotateIcon,
copyIcon,
fileTextIcon,
activityIcon,
handleToggleDaemonManagement,
isUpdatingDaemonManagement,
keepRunningAfterQuit,
handleToggleKeepRunningAfterQuit,
isUpdatingKeepRunningAfterQuit,
daemonActionLabel,
daemonActionMessage,
statusMessage,
handleUpdateLocalDaemon,
isRestartingDaemon,
daemonStatus,
daemonLogs,
handleCopyLogPath,
handleOpenLogs,
@@ -313,7 +400,9 @@ 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>
@@ -322,33 +411,44 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
<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>
</View>
<Switch
value={!isDaemonManagementPaused}
onValueChange={handleToggleDaemonManagement}
<Button
variant="outline"
size="sm"
leftIcon={isDaemonManagementPaused ? playIcon : pauseIcon}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
accessibilityLabel="Manage built-in daemon"
/>
>
{getDaemonManagementButtonLabel(isUpdatingDaemonManagement, isDaemonManagementPaused)}
</Button>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Switch
value={keepRunningAfterQuit}
onValueChange={handleToggleKeepRunningAfterQuit}
disabled={isUpdatingKeepRunningAfterQuit}
accessibilityLabel="Keep daemon running after quit"
/>
<Button
variant="outline"
size="sm"
leftIcon={rotateIcon}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{getDaemonRestartButtonLabel(isRestartingDaemon, daemonStatus?.status, daemonActionLabel)}
</Button>
</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}>
@@ -372,7 +472,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
@@ -393,12 +493,7 @@ export function LocalDaemonSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const appVersion = resolveAppVersion();
const { settings, updateSettings, isLoading: isLoadingSettings } = useDesktopSettings();
const daemonSettings = settings.daemon;
const updateDaemonSettings = useCallback(
(updates: Partial<DesktopDaemonSettings>) => updateSettings({ daemon: updates }),
[updateSettings],
);
const { settings, updateSettings } = useAppSettings();
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
const daemonStatus = data?.status ?? null;
@@ -409,20 +504,30 @@ export function LocalDaemonSection() {
const daemonStatusStateText =
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
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 { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({
daemonStatus,
settings: daemonSettings,
updateSettings: updateDaemonSettings,
settings,
updateSettings,
setStatus,
setStatusMessage,
refetch,
});
const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } =
useKeepRunningAfterQuitToggle({
settings: daemonSettings,
updateSettings: updateDaemonSettings,
});
const { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal } =
useDaemonLogsModal(daemonLogs);
@@ -432,12 +537,9 @@ export function LocalDaemonSection() {
isCliStatusModalOpen,
isLoadingCliStatus,
handleCopyCliStatus,
handleOpenCliStatus,
handleRunCliStatus,
handleCloseCliStatusModal,
} = useDaemonCliStatusModal();
const handleRunCliStatus = useCallback(() => {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleOpenAdvancedSettings = useCallback(
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
@@ -448,6 +550,18 @@ 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],
@@ -488,7 +602,7 @@ export function LocalDaemonSection() {
trailing={advancedSettingsButton}
testID="host-page-daemon-lifecycle-card"
>
{isLoading || isLoadingSettings ? (
{isLoading ? (
<View style={LOADING_CARD_STYLE}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
@@ -498,14 +612,20 @@ 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}
keepRunningAfterQuit={daemonSettings.keepRunningAfterQuit}
handleToggleKeepRunningAfterQuit={handleToggleKeepRunningAfterQuit}
isUpdatingKeepRunningAfterQuit={isUpdatingKeepRunningAfterQuit}
daemonActionLabel={daemonActionLabel}
daemonActionMessage={daemonActionMessage}
statusMessage={statusMessage}
handleUpdateLocalDaemon={handleUpdateLocalDaemon}
isRestartingDaemon={isRestartingDaemon}
daemonStatus={daemonStatus}
daemonLogs={daemonLogs}
handleCopyLogPath={handleCopyLogPath}
handleOpenLogs={handleOpenLogs}
@@ -567,6 +687,11 @@ 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,7 +133,9 @@ 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}>
@@ -158,7 +160,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 ? (
@@ -1,138 +0,0 @@
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);
}
@@ -1,11 +1,8 @@
import { Gift } from "lucide-react-native";
import { type ReactNode, useEffect, useRef } from "react";
import { useUnistyles } from "react-native-unistyles";
import {
type SidebarCalloutAction,
SidebarCalloutDescriptionText,
} from "@/components/sidebar-callout";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { CalloutDescriptionText } from "@/components/callout-card";
import { type SidebarCalloutAction, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { useStableEvent } from "@/hooks/use-stable-event";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -170,14 +167,14 @@ export function UpdateCalloutSource() {
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
return (
<>
<SidebarCalloutDescriptionText>
<CalloutDescriptionText>
{versionLabel
? `${versionLabel} is ready to install.`
: "A new version is ready to install."}
</SidebarCalloutDescriptionText>
<SidebarCalloutDescriptionText>
</CalloutDescriptionText>
<CalloutDescriptionText>
Upgrading the app will stop running agents and close terminal sessions.
</SidebarCalloutDescriptionText>
</CalloutDescriptionText>
</>
);
}
@@ -1,71 +0,0 @@
/**
* @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 { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useAppSettings } from "@/hooks/use-settings";
export type DesktopAppUpdateStatus =
| "idle"
@@ -84,8 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings: desktopSettings } = useDesktopSettings();
const releaseChannel = desktopSettings.releaseChannel;
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -78,7 +78,6 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "codex",
status: "ready",
enabled: true,
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
@@ -108,7 +107,6 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
@@ -53,7 +53,6 @@ describe("useAgentFormState", () => {
{
provider: "zai",
status: "ready",
enabled: true,
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
@@ -70,7 +69,6 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
@@ -546,7 +544,6 @@ describe("useAgentFormState", () => {
{
provider: "codex",
status: "loading",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
@@ -555,7 +552,6 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
@@ -600,111 +596,11 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("ignores disabled ready providers when resolving selectable defaults", () => {
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 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,
@@ -713,7 +609,6 @@ describe("useAgentFormState", () => {
{
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) && entry.enabled !== false)
.filter((entry) => args.statuses.has(entry.status))
.map((entry) => entry.provider),
);
-280
View File
@@ -1,280 +0,0 @@
/**
* @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
@@ -1,138 +0,0 @@
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,7 +101,6 @@ function codexEntry(
return {
provider: "codex",
status,
enabled: true,
...(models ? { models } : {}),
};
}
+6 -106
View File
@@ -5,33 +5,15 @@ 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 () => {
@@ -44,7 +26,7 @@ describe("use-settings", () => {
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS),
JSON.stringify(mod.DEFAULT_APP_SETTINGS),
);
});
@@ -68,7 +50,7 @@ describe("use-settings", () => {
expect(result.releaseChannel).toBe("stable");
});
it("ignores renderer-owned daemon management state outside Electron", async () => {
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
@@ -84,13 +66,14 @@ describe("use-settings", () => {
expect(result).toEqual({
theme: "light",
manageBuiltInDaemon: true,
manageBuiltInDaemon: false,
sendBehavior: "interrupt",
releaseChannel: "stable",
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("ignores renderer-owned release channel outside Electron", async () => {
it("loads persisted beta release channel", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
@@ -103,89 +86,6 @@ describe("use-settings", () => {
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
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",
});
expect(result.releaseChannel).toBe("beta");
});
});
+29 -170
View File
@@ -1,66 +1,46 @@
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";
sendBehavior: SendBehavior;
}
export interface Settings extends AppSettings {
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
releaseChannel: ReleaseChannel;
}
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
sendBehavior: "interrupt",
};
export const DEFAULT_APP_SETTINGS: Settings = {
...DEFAULT_CLIENT_SETTINGS,
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
releaseChannel: "stable",
};
export interface UseAppSettingsReturn {
settings: AppSettings;
isLoading: boolean;
error: unknown;
error: unknown | null;
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: loadAppSettingsFromStorage,
queryFn: loadSettingsFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
@@ -69,7 +49,7 @@ export function useAppSettings(): UseAppSettingsReturn {
async (updates: Partial<AppSettings>) => {
try {
const prev =
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS;
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
const next = { ...prev, ...updates };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
@@ -83,7 +63,7 @@ export function useAppSettings(): UseAppSettingsReturn {
const resetSettings = useCallback(async () => {
try {
const next = { ...DEFAULT_CLIENT_SETTINGS };
const next = { ...DEFAULT_APP_SETTINGS };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
} catch (err) {
@@ -93,7 +73,7 @@ export function useAppSettings(): UseAppSettingsReturn {
}, [queryClient]);
return {
settings: data ?? DEFAULT_CLIENT_SETTINGS,
settings: data ?? DEFAULT_APP_SETTINGS,
isLoading: isPending,
error: error ?? null,
updateSettings,
@@ -101,172 +81,51 @@ export function useAppSettings(): UseAppSettingsReturn {
};
}
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> {
export async function loadSettingsFromStorage(): Promise<AppSettings> {
try {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>;
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) };
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 };
}
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
if (legacyStored) {
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
const next = {
...DEFAULT_CLIENT_SETTINGS,
...DEFAULT_APP_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_CLIENT_SETTINGS));
return DEFAULT_CLIENT_SETTINGS;
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS));
return DEFAULT_APP_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;
}
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>;
}
export const useSettings = useAppSettings;
@@ -21,7 +21,6 @@ export interface SidebarWorkspaceEntry {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKey: string;
projectRootPath?: string;
workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"];
@@ -58,7 +57,6 @@ 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,
@@ -79,7 +77,6 @@ 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,
+1 -1
View File
@@ -108,7 +108,7 @@ vi.mock("react-native-unistyles", () => {
StyleSheet: {
create: createPanelTestStyles,
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
};
});
+10 -16
View File
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -44,7 +44,6 @@ import {
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { type Agent, useSessionStore } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
@@ -126,8 +125,9 @@ function buildChatAgentFromState(
function renderChatAgentNonReadyView(args: {
viewState: AgentScreenViewState;
effectiveAgent: AgentScreenAgent | null;
theme: ReturnType<typeof useUnistyles>["theme"];
}): React.ReactElement | null {
const { viewState, effectiveAgent } = args;
const { viewState, effectiveAgent, theme } = args;
if (viewState.tag === "not_found") {
return (
<View style={styles.container} testID="agent-not-found">
@@ -151,7 +151,7 @@ function renderChatAgentNonReadyView(args: {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
</View>
);
@@ -419,6 +419,7 @@ function AgentPanelBody({
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const { theme } = useUnistyles();
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const projectPlacement = useStoreWithEqualityFn(
@@ -548,7 +549,7 @@ function AgentPanelBody({
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
</View>
);
@@ -584,6 +585,7 @@ function ChatAgentContent({
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const { theme } = useUnistyles();
const panelToast = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
@@ -943,6 +945,7 @@ function ChatAgentContent({
const nonReadyView = renderChatAgentNonReadyView({
viewState,
effectiveAgent,
theme,
});
if (nonReadyView) return nonReadyView;
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
@@ -987,7 +990,7 @@ function ChatAgentContent({
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
@@ -1001,7 +1004,7 @@ function ChatAgentContent({
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
@@ -1337,15 +1340,6 @@ function AgentSessionUnavailableState({
);
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
+14 -29
View File
@@ -9,12 +9,11 @@ import {
View,
} from "react-native";
import invariant from "tiny-invariant";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme";
import {
useWorkspaceSetupStore,
type WorkspaceSetupSnapshot,
@@ -63,13 +62,15 @@ function useSetupPanelDescriptor(
type CommandStatus = "running" | "completed" | "failed";
function CommandStatusIcon({ status }: { status: CommandStatus }) {
const { theme } = useUnistyles();
if (status === "running") {
return <ThemedActivityIndicator size={14} uniProps={foregroundColorMapping} />;
return <ActivityIndicator size={14} color={theme.colors.foreground} />;
}
if (status === "completed") {
return <ThemedCheckCircle2 size={14} uniProps={greenColorMapping} />;
return <CheckCircle2 size={14} color={theme.colors.palette.green[500]} />;
}
return <ThemedCircleAlert size={14} uniProps={redColorMapping} />;
return <CircleAlert size={14} color={theme.colors.palette.red[500]} />;
}
function formatDuration(ms: number): string {
@@ -150,6 +151,7 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
}
function SetupPanel() {
const { theme } = useUnistyles();
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
@@ -229,7 +231,7 @@ function SetupPanel() {
{isWaiting ? (
<View style={styles.waitingContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.waitingText}>Setting up workspace...</Text>
</View>
) : null}
@@ -267,6 +269,7 @@ function SetupPanel() {
hasError={rowState.hasError}
processedLog={rowState.processedLog}
errorMessage={snapshot?.error ?? null}
foregroundMutedColor={theme.colors.foregroundMuted}
onToggle={toggleExpanded}
/>
);
@@ -289,6 +292,7 @@ interface SetupCommandRowProps {
hasError: boolean;
processedLog: string;
errorMessage: string | null;
foregroundMutedColor: string;
onToggle: (index: number, isAutoExpanded: boolean) => void;
}
@@ -301,6 +305,7 @@ function SetupCommandRow({
hasError,
processedLog,
errorMessage,
foregroundMutedColor,
onToggle,
}: SetupCommandRowProps) {
const handlePress = useCallback(() => {
@@ -336,7 +341,7 @@ function SetupCommandRow({
{command.durationMs != null ? (
<Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
) : null}
<SetupCommandChevron showDetail={showDetail} />
<SetupCommandChevron showDetail={showDetail} color={foregroundMutedColor} />
</Pressable>
{showDetail ? (
<View style={styles.commandDetail}>
@@ -383,14 +388,12 @@ export const setupPanelRegistration: PanelRegistration<"setup"> = {
useDescriptor: useSetupPanelDescriptor,
};
function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
function SetupCommandChevron({ showDetail, color }: { showDetail: boolean; color: string }) {
const chevronStyle = useMemo(
() => [styles.chevron, showDetail && styles.chevronExpanded],
[showDetail],
);
return (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} style={chevronStyle} />
);
return <ChevronRight size={14} color={color} style={chevronStyle} />;
}
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
@@ -429,24 +432,6 @@ function TopLevelSetupError({
);
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCheckCircle2 = withUnistyles(CheckCircle2);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedChevronRight = withUnistyles(ChevronRight);
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.palette.green[500],
});
const redColorMapping = (theme: Theme) => ({
color: theme.colors.palette.red[500],
});
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -282,72 +282,6 @@ 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]
@@ -1224,113 +1158,6 @@ 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: [
+21 -104
View File
@@ -70,10 +70,6 @@ export interface HostRuntimeSnapshot {
clientGeneration: number;
}
export interface StartupHostResolution {
serverId: string;
}
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
@@ -1155,8 +1151,6 @@ 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 {
@@ -1745,115 +1739,38 @@ export class HostRuntimeStore {
};
}
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 earliestServerId;
}
waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): {
promise: Promise<StartupHostResolution | null>;
cancel: () => void;
} {
waitForAnyConnectionOnline(): { promise: Promise<void>; 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;
const isAnyOnline = (): boolean => {
for (const host of this.hosts) {
const snapshot = this.getSnapshot(host.serverId);
if (snapshot?.connectionStatus === "online") return true;
}
if (noHostOnlineTimeout) {
clearTimeout(noHostOnlineTimeout);
noHostOnlineTimeout = null;
}
unsubscribe?.();
unsubscribe = null;
return false;
};
const promise = new Promise<StartupHostResolution | null>((resolve) => {
const settle = (result: StartupHostResolution | null): void => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(result);
};
const promise = new Promise<void>((resolve) => {
if (isAnyOnline()) {
resolve();
return;
}
const getPreferredOnlineServerId = (): string | null => {
if (!normalizedPreferredServerId) {
return null;
unsubscribe = this.subscribeAll(() => {
if (isAnyOnline()) {
unsubscribe?.();
unsubscribe = null;
resolve();
}
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: cleanup,
cancel: () => {
unsubscribe?.();
unsubscribe = null;
},
};
}
@@ -97,7 +97,11 @@ const {
const hoistedMockClient = {
isConnected: true,
getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })),
getCheckoutStatus: vi.fn(
async (): Promise<{ currentBranch: string | null }> => ({
currentBranch: "main",
}),
),
getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })),
searchGitHub: vi.fn(async () => ({
items: [hoistedPrItem, hoistedPrItemB],
@@ -672,7 +676,7 @@ describe("NewWorkspaceScreen picker payload", () => {
});
});
it("sends no new fields when nothing is selected (default)", async () => {
it("uses the loaded current branch as the selected branch", async () => {
renderScreen();
await flush();
@@ -684,12 +688,24 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
action: "branch-off",
refName: "main",
});
expect(call).not.toHaveProperty("refName");
expect(call).not.toHaveProperty("action");
expect(call).not.toHaveProperty("githubPrNumber");
});
it("requires a branch selection when the current branch is unavailable", async () => {
mockClient.getCheckoutStatus.mockResolvedValueOnce({ currentBranch: null });
renderScreen();
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Choose a branch to start from");
});
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
initialDraftState.text = "please review this change";
renderScreen();
@@ -194,6 +194,15 @@ function pickerItemTriggerLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function resolveSelectedPickerItem(input: {
pickerSelection: PickerSelection | null;
currentBranch: string | null | undefined;
}): PickerItem | null {
if (input.pickerSelection) return input.pickerSelection.item;
const currentBranch = input.currentBranch?.trim();
return currentBranch ? { kind: "branch", name: currentBranch } : null;
}
function syncPickerPrAttachment(input: {
attachments: ComposerAttachment[];
previousPickerPrNumber: number | null;
@@ -436,7 +445,6 @@ export function NewWorkspaceScreen({
const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace;
const selectedItem = pickerSelection?.item ?? null;
const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -476,7 +484,10 @@ export function NewWorkspaceScreen({
refetchOnWindowFocus: false,
});
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
const selectedItem = resolveSelectedPickerItem({
pickerSelection,
currentBranch: checkoutStatusQuery.data?.currentBranch,
});
const branchSuggestionsQuery = useQuery({
queryKey: ["branch-suggestions", serverId, sourceDirectory, debouncedPickerSearchQuery],
@@ -527,8 +538,8 @@ export function NewWorkspaceScreen({
const triggerLabel = useMemo(() => {
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
return currentBranch ?? "main";
}, [currentBranch, selectedItem]);
return "Select branch";
}, [selectedItem]);
const selectedOptionId = useMemo(() => {
if (!selectedItem) return "";
@@ -588,6 +599,9 @@ export function NewWorkspaceScreen({
const buildCreateWorktreeInput = useCallback(
(input: { cwd: string; attachments: AgentAttachment[] }) => {
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
if (!checkoutRequest) {
throw new Error("Choose a branch to start from");
}
return {
cwd: input.cwd,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,418 +0,0 @@
/**
* @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");
});
});
@@ -1,213 +0,0 @@
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,
},
}));
+18 -93
View File
@@ -27,20 +27,13 @@ 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,
useSettings,
type AppSettings,
type SendBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import { useAppSettings, type AppSettings, type SendBehavior } 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";
@@ -71,14 +64,11 @@ 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,
@@ -92,9 +82,7 @@ import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-ac
export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
| { kind: "host"; serverId: string };
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -366,7 +354,7 @@ function getUpdateButtonLabel(
}
function DesktopAppUpdateRow() {
const { settings, updateSettings } = useSettings();
const { settings, updateSettings } = useAppSettings();
const {
isDesktopApp,
statusText,
@@ -396,7 +384,7 @@ function DesktopAppUpdateRow() {
}, [checkForUpdates, isDesktopApp]);
const handleReleaseChannelChange = useCallback(
(releaseChannel: EffectiveSettings["releaseChannel"]) => {
(releaseChannel: AppSettings["releaseChannel"]) => {
void updateSettings({ releaseChannel });
},
[updateSettings],
@@ -550,37 +538,6 @@ 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;
@@ -627,7 +584,6 @@ interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
layout: "desktop" | "mobile";
@@ -637,7 +593,6 @@ function SettingsSidebar({
view,
onSelectSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
layout,
@@ -665,7 +620,6 @@ 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 (
@@ -695,7 +649,6 @@ function SettingsSidebar({
onSelect={onSelectSection}
/>
))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
@@ -859,15 +812,6 @@ 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({
@@ -894,11 +838,17 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]);
const handleBackToWorkspace = useCallback(() => {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
if (!isCompactLayout) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
}
if (router.canGoBack()) {
router.back();
return;
}
if (anyOnlineServerId) {
@@ -906,7 +856,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return;
}
router.replace("/");
}, [anyOnlineServerId, router]);
}, [anyOnlineServerId, isCompactLayout, router]);
const detailHeader = ((): {
title: string;
@@ -927,9 +877,6 @@ 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;
})();
@@ -937,12 +884,6 @@ 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":
@@ -1017,7 +958,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="mobile"
@@ -1028,18 +968,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
);
}
// 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;
// Mobile detail: full-screen content with a back header that returns to the list.
if (isCompactLayout) {
return (
<View style={styles.container}>
<BackHeader
title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory}
onBack={detailBackHandler}
onBack={handleBackToRoot}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
@@ -1059,7 +995,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="desktop"
@@ -1153,16 +1088,6 @@ 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 { Switch } from "@/components/ui/switch";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
@@ -29,6 +29,11 @@ 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})`;
@@ -574,7 +579,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
@@ -597,10 +602,10 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { config, patchConfig } = useDaemonConfig(serverId);
const handleValueChange = useCallback(
(next: boolean) => {
(value: string) => {
void patchConfig({
mcp: {
injectIntoAgents: next,
injectIntoAgents: value === "on",
},
});
},
@@ -618,10 +623,11 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
Automatically inject Paseo MCP tools into new agents
</Text>
</View>
<Switch
value={config?.mcp.injectIntoAgents !== false}
<SegmentedControl
size="sm"
value={config?.mcp.injectIntoAgents === false ? "off" : "on"}
onValueChange={handleValueChange}
accessibilityLabel="Inject Paseo tools"
options={INJECT_TOOLS_OPTIONS}
/>
</View>
</View>
@@ -1,347 +0,0 @@
/**
* @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,173 +1,80 @@
import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Pressable, Text, View } 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 { Switch } from "@/components/ui/switch";
import { StatusBadge } from "@/components/ui/status-badge";
import { SettingsSection } from "@/screens/settings/settings-section";
import { ChevronRight, RotateCw } from "lucide-react-native";
import { RotateCw } from "lucide-react-native";
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
type StatusTone = "success" | "warning" | "danger" | "muted" | "loading";
interface ProviderStatus {
tone: StatusTone;
label: string;
modelCount: number | null;
function getProviderStatusLabel(status: string): string {
if (status === "ready") return "Available";
if (status === "error") return "Error";
if (status === "loading") return "Loading...";
return "Not installed";
}
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 };
function getProviderStatusVariant(status: string): "success" | "error" | "muted" {
if (status === "ready") return "success";
if (status === "error") return "error";
return "muted";
}
interface ProviderRowProps {
def: ProviderDefinition;
entry: ProviderEntry;
enabled: boolean;
isToggling: boolean;
entry: ProviderEntry | undefined;
isFirst: boolean;
onPress: (providerId: string) => void;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
}
function ProviderRow({
def,
entry,
enabled,
isToggling,
isFirst,
onPress,
onToggleEnabled,
}: ProviderRowProps) {
function ProviderRow({ def, entry, isFirst, onPress }: ProviderRowProps) {
const { theme } = useUnistyles();
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
const providerError =
enabled &&
entry.status === "error" &&
typeof entry.error === "string" &&
entry.error.trim().length > 0
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
? entry.error.trim()
: null;
const modelCount = entry.models?.length ?? 0;
const providerStatus = getProviderStatus(entry.status, enabled, modelCount);
const modelCount = entry?.models?.length ?? 0;
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,
],
const handlePress = useCallback(() => onPress(def.id), [def.id, onPress]);
const rowStyle = useMemo(
() => [settingsStyles.row, !isFirst && settingsStyles.rowBorder],
[isFirst],
);
return (
<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`}
<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}
</View>
) : 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>
);
}
@@ -179,11 +86,8 @@ 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 [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const providerDefinitions = buildProviderDefinitions(entries);
const providerRefreshInFlight =
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = serverId.length > 0;
@@ -193,22 +97,6 @@ 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(
() =>
@@ -258,24 +146,17 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : null}
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
{hasServer && isConnected && !isLoading ? (
<View style={settingsStyles.card}>
{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}
/>
);
})}
{providerDefinitions.map((def, index) => (
<ProviderRow
key={def.id}
def={def}
entry={entries?.find((e) => e.provider === def.id)}
isFirst={index === 0}
onPress={setDiagnosticProvider}
/>
))}
</View>
) : null}
</SettingsSection>
@@ -304,49 +185,11 @@ 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,25 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from "react";
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 { ActivityIndicator, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { BookOpen, Check, 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 { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
interface StartupSplashScreenProps {
@@ -33,10 +23,6 @@ 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);
}
@@ -45,141 +31,6 @@ 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",
@@ -190,6 +41,10 @@ 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,
@@ -213,6 +68,12 @@ 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,
@@ -223,11 +84,43 @@ 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,
@@ -274,29 +167,10 @@ 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();
@@ -312,7 +186,9 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const isError = bootstrapState?.phase === "error";
const phase = bootstrapState?.phase;
const isError = phase === "error";
const isSimpleSplash = bootstrapState === undefined;
useEffect(() => {
if (!isError) {
@@ -353,6 +229,23 @@ 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...";
@@ -390,11 +283,36 @@ 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 />
<LogoShimmer />
<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>
</View>
);
}
@@ -410,7 +328,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={styles.title}>Something went wrong</Text>
<Text style={TITLE_ERROR_STYLE}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
@@ -28,7 +28,7 @@ import {
SquareTerminal,
X,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import type {
DraggableListDragHandleProps,
@@ -58,53 +58,39 @@ import {
type WorkspaceTabMenuEntry,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedX = withUnistyles(X);
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
const ThemedCopyX = withUnistyles(CopyX);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
}
function TabContextMenuItem({
entry,
iconColor,
}: {
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
iconColor: string;
}) {
const leading = useMemo(() => {
switch (entry.icon) {
case "copy":
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
return <Copy size={16} color={iconColor} />;
case "rotate-cw":
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
return <RotateCw size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
return <CopyX size={16} color={iconColor} />;
case "x":
return <ThemedX size={16} uniProps={mutedColorMapping} />;
return <X size={16} color={iconColor} />;
default:
return undefined;
}
}, [entry.icon]);
}, [entry.icon, iconColor]);
const trailing = useMemo(
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
[entry.hint],
@@ -269,6 +255,7 @@ function TabChip({
onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: DraggableListDragHandleProps | undefined;
}) {
const { theme } = useUnistyles();
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
@@ -405,17 +392,21 @@ function TabChip({
>
{({ hovered: closeHovered, pressed }) =>
isClosingTab ? (
<ThemedActivityIndicator
<ActivityIndicator
size={12}
uniProps={
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
color={
closeHovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<ThemedX
<X
size={12}
uniProps={
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
color={
closeHovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
@@ -441,7 +432,11 @@ function TabChip({
entry.kind === "separator" ? (
<ContextMenuSeparator key={entry.key} />
) : (
<TabContextMenuItem key={entry.key} entry={entry} />
<TabContextMenuItem
key={entry.key}
entry={entry}
iconColor={theme.colors.foregroundMuted}
/>
),
)}
</ContextMenuContent>
@@ -478,6 +473,7 @@ export function WorkspaceDesktopTabsRow({
tabDropPreviewIndex = null,
showPaneSplitActions = true,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const newTabKeys = useShortcutKeys("workspace-tab-new");
const newTerminalKeys = useShortcutKeys("workspace-terminal-new");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
@@ -503,11 +499,11 @@ export function WorkspaceDesktopTabsRow({
tabGap: 0,
maxTabWidth: 200,
tabIconWidth: 14,
tabHorizontalPadding: 12,
tabHorizontalPadding: theme.spacing[3],
estimatedCharWidth: 7,
closeButtonWidth: 22,
}),
[tabsActionsWidth],
[tabsActionsWidth, theme.spacing],
);
const tabLabelLengths = useMemo(
@@ -672,7 +668,7 @@ export function WorkspaceDesktopTabsRow({
accessibilityLabel="New agent tab"
style={newTabActionButtonStyle}
>
<ThemedSquarePen size={14} uniProps={mutedColorMapping} />
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
@@ -694,7 +690,7 @@ export function WorkspaceDesktopTabsRow({
}
style={newTerminalActionButtonStyle}
>
<ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
@@ -716,7 +712,7 @@ export function WorkspaceDesktopTabsRow({
accessibilityLabel="Split pane right"
style={newTabActionButtonStyle}
>
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
@@ -734,7 +730,7 @@ export function WorkspaceDesktopTabsRow({
accessibilityLabel="Split pane down"
style={newTabActionButtonStyle}
>
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
@@ -1,4 +1,5 @@
import { withUnistyles } from "react-native-unistyles";
import { useMemo } from "react";
import { useUnistyles } from "react-native-unistyles";
import {
Archive,
Download,
@@ -10,7 +11,6 @@ import {
import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions";
import type { Theme } from "@/styles/theme";
interface WorkspaceGitActionsProps {
serverId: string;
@@ -18,31 +18,24 @@ interface WorkspaceGitActionsProps {
hideLabels?: boolean;
}
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const mutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const ICONS = {
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
};
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS });
const { theme } = useUnistyles();
const icons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
merge: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
mergeFromBase: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
archive: <Archive size={16} color={theme.colors.foregroundMuted} />,
}),
[theme.colors.foregroundMuted],
);
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons });
if (!isGit) {
return null;
@@ -8,7 +8,7 @@ import {
} from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import {
@@ -22,7 +22,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
@@ -34,25 +33,18 @@ interface EditorMenuItemProps {
editor: EditorTargetDescriptorPayload;
isPreferred: boolean;
onOpen: (editorId: EditorTargetId) => void;
foregroundMuted: string;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEditorAppIcon = withUnistyles(EditorAppIcon);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCheckIcon = withUnistyles(Check);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function EditorMenuItem({ editor, isPreferred, onOpen }: EditorMenuItemProps) {
function EditorMenuItem({ editor, isPreferred, onOpen, foregroundMuted }: EditorMenuItemProps) {
const handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]);
const leading = useMemo(
() => <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
[editor.id],
() => <EditorAppIcon editorId={editor.id} size={16} color={foregroundMuted} />,
[editor.id, foregroundMuted],
);
const trailing = useMemo(
() => (isPreferred ? <ThemedCheckIcon size={16} uniProps={mutedColorMapping} /> : undefined),
[isPreferred],
() => (isPreferred ? <Check size={16} color={foregroundMuted} /> : undefined),
[isPreferred, foregroundMuted],
);
return (
<DropdownMenuItem
@@ -71,6 +63,7 @@ export function WorkspaceOpenInEditorButton({
cwd,
hideLabels,
}: WorkspaceOpenInEditorButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -181,17 +174,17 @@ export function WorkspaceOpenInEditorButton({
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
>
{openMutation.isPending ? (
<ThemedActivityIndicator
<ActivityIndicator
size="small"
uniProps={foregroundColorMapping}
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}
/>
) : (
<View style={styles.splitButtonContent}>
<ThemedEditorAppIcon
<EditorAppIcon
editorId={primaryOption.id}
size={16}
uniProps={mutedColorMapping}
color={theme.colors.foregroundMuted}
/>
{!hideLabels && <Text style={styles.splitButtonText}>Open</Text>}
</View>
@@ -205,7 +198,7 @@ export function WorkspaceOpenInEditorButton({
accessibilityRole="button"
accessibilityLabel="Choose editor"
>
<ThemedChevronDown size={16} uniProps={mutedColorMapping} />
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
@@ -219,6 +212,7 @@ export function WorkspaceOpenInEditorButton({
editor={editor}
isPreferred={editor.id === effectivePreferredEditorId}
onOpen={handleOpenEditor}
foregroundMuted={theme.colors.foregroundMuted}
/>
))}
</DropdownMenuContent>
@@ -22,8 +22,7 @@ import {
} from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
@@ -136,32 +135,6 @@ const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
const ThemedCopyX = withUnistyles(CopyX);
const ThemedX = withUnistyles(X);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight);
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
interface WorkspaceScreenProps {
serverId: string;
workspaceId: string;
@@ -325,10 +298,14 @@ function MobileTabTrailingAccessory({
menuTestIDBase,
presentationLabel,
menuEntries,
iconColor,
iconSizeSm,
}: {
menuTestIDBase: string;
presentationLabel: string;
menuEntries: WorkspaceTabMenuEntry[];
iconColor: string;
iconSizeSm: number;
}) {
return (
<DropdownMenu>
@@ -339,14 +316,14 @@ function MobileTabTrailingAccessory({
hitSlop={8}
style={mobileTabMenuTriggerStyle}
>
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
<Ellipsis size={iconSizeSm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<DropdownMenuSeparator key={entry.key} />
) : (
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
<MobileTabDropdownMenuItem key={entry.key} entry={entry} iconColor={iconColor} />
),
)}
</DropdownMenuContent>
@@ -356,27 +333,29 @@ function MobileTabTrailingAccessory({
function MobileTabDropdownMenuItem({
entry,
iconColor,
}: {
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
iconColor: string;
}) {
const leading = useMemo(() => {
switch (entry.icon) {
case "copy":
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
return <Copy size={16} color={iconColor} />;
case "rotate-cw":
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
return <RotateCw size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
return <CopyX size={16} color={iconColor} />;
case "x":
return <ThemedX size={16} uniProps={mutedColorMapping} />;
return <X size={16} color={iconColor} />;
default:
return undefined;
}
}, [entry.icon]);
}, [entry.icon, iconColor]);
const trailing = useMemo(
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
[entry.hint],
@@ -429,6 +408,7 @@ function MobileWorkspaceTabOption({
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) {
const { theme } = useUnistyles();
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
@@ -445,6 +425,9 @@ function MobileWorkspaceTabOption({
onCloseOtherTabs,
});
const iconColor = theme.colors.foregroundMuted;
const iconSizeSm = theme.iconSize.sm;
const fallbackLabel = getFallbackTabOptionLabel(tab);
const trailingAccessory = useMemo(
() => (
@@ -452,9 +435,11 @@ function MobileWorkspaceTabOption({
menuTestIDBase={menuTestIDBase}
presentationLabel={fallbackLabel}
menuEntries={menuEntries}
iconColor={iconColor}
iconSizeSm={iconSizeSm}
/>
),
[menuTestIDBase, fallbackLabel, menuEntries],
[menuTestIDBase, fallbackLabel, menuEntries, iconColor, iconSizeSm],
);
const renderPresentation = useCallback(
@@ -498,6 +483,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onCloseTabsBelow,
onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) {
const { theme } = useUnistyles();
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const tabIndexByKey = useMemo(() => {
@@ -586,7 +572,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
normalizedWorkspaceId={normalizedWorkspaceId}
/>
</View>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
@@ -744,9 +730,14 @@ function WorkspaceHeaderMenuTriggerIcon({
open: boolean;
isMobile: boolean;
}) {
const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis;
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
return <Icon size={16} uniProps={colorMapping} />;
const { theme } = useUnistyles();
const Icon = isMobile ? EllipsisVertical : Ellipsis;
return (
<Icon
size={theme.iconSize.md}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
);
}
function WorkspaceHeaderMenu({
@@ -948,6 +939,7 @@ interface RenderWorkspaceContentInput {
paneId: string | null;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
foregroundMutedColor: string;
}
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
@@ -961,12 +953,13 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
isRouteFocused,
focusedPaneId,
buildMobilePaneContentModel,
foregroundMutedColor,
} = input;
if (showMissingWorkspaceDescriptor) {
return (
<View style={styles.emptyState}>
<ThemedActivityIndicator uniProps={mutedColorMapping} />
<ActivityIndicator color={foregroundMutedColor} />
</View>
);
}
@@ -982,7 +975,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
if (!activeTabDescriptor && !hasHydratedAgents) {
return (
<View style={styles.emptyState}>
<ThemedActivityIndicator uniProps={mutedColorMapping} />
<ActivityIndicator color={foregroundMutedColor} />
</View>
);
}
@@ -1122,7 +1115,9 @@ function WorkspaceScreenContent({
workspaceId,
isRouteFocused,
}: WorkspaceScreenContentProps) {
const { theme } = useUnistyles();
const _insets = useSafeAreaInsets();
const mainBackgroundColor = theme.colors.surfaceWorkspace;
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
@@ -2567,6 +2562,7 @@ function WorkspaceScreenContent({
isRouteFocused,
focusedPaneId,
buildMobilePaneContentModel,
foregroundMutedColor: theme.colors.foregroundMuted,
});
const buildDesktopPaneContentModel = useCallback(
@@ -2663,12 +2659,27 @@ function WorkspaceScreenContent({
);
}, []);
const containerStyle = containerWithWorkspaceBackgroundStyle;
const containerStyle = useMemo(
() => [styles.container, { backgroundColor: mainBackgroundColor }],
[mainBackgroundColor],
);
const menuNewAgentIcon = MENU_NEW_AGENT_ICON;
const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON;
const menuCopyIcon = MENU_COPY_ICON;
const menuSettingsIcon = MENU_SETTINGS_ICON;
const menuNewAgentIcon = useMemo(
() => <SquarePen size={16} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const menuNewTerminalIcon = useMemo(
() => <SquareTerminal size={16} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const menuCopyIcon = useMemo(
() => <Copy size={16} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const menuSettingsIcon = useMemo(
() => <Settings size={16} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const headerRight = useMemo(
() => (
@@ -2710,10 +2721,12 @@ function WorkspaceScreenContent({
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
const iconColor = active
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
@@ -2753,9 +2766,9 @@ function WorkspaceScreenContent({
accessibilityState={explorerToggleAccessibilityState}
>
{({ hovered }) => {
const colorMapping =
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
return <ThemedPanelRight size={16} uniProps={colorMapping} />;
const color =
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return <PanelRight size={theme.iconSize.md} color={color} />;
}}
</HeaderToggleButton>
) : null}
@@ -2773,16 +2786,12 @@ function WorkspaceScreenContent({
accessibilityState={explorerToggleAccessibilityState}
>
{({ hovered }) => {
const colorMapping =
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
const color =
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return isGitCheckout ? (
<ThemedSourceControlPanelIcon
size={20}
uniProps={colorMapping}
{...sourceControlPanelStrokeWidth15}
/>
<SourceControlPanelIcon size={theme.iconSize.lg} color={color} strokeWidth={1.5} />
) : (
<ThemedPanelRight size={20} uniProps={colorMapping} />
<PanelRight size={theme.iconSize.lg} color={color} />
);
}}
</HeaderToggleButton>
@@ -2803,6 +2812,10 @@ function WorkspaceScreenContent({
isExplorerOpen,
explorerToggleAccessibilityState,
explorerToggleStyle,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.iconSize.md,
theme.iconSize.lg,
],
);
@@ -3026,9 +3039,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
containerWorkspaceBackground: {
backgroundColor: theme.colors.surfaceWorkspace,
},
threePaneRow: {
flex: 1,
minHeight: 0,
@@ -3323,9 +3333,4 @@ const styles = StyleSheet.create((theme) => ({
},
}));
const containerWithWorkspaceBackgroundStyle = [
styles.container,
styles.containerWorkspaceBackground,
];
const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
@@ -43,17 +43,7 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({
uniProps,
...rest
}: {
uniProps?: (theme: unknown) => Record<string, unknown>;
} & Record<string, unknown>) => {
const themed = uniProps ? uniProps(theme) : {};
return React.createElement(Component, { ...rest, ...themed });
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
@@ -3,7 +3,7 @@ import type { GestureResponderEvent } from "react-native";
import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
@@ -17,7 +17,6 @@ import { useToast } from "@/contexts/toast-context";
import { isNative } from "@/constants/platform";
import { openExternalUrl } from "@/utils/open-external-url";
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
import type { Theme } from "@/styles/theme";
type ScriptActionIcon = "start" | "view";
@@ -40,48 +39,28 @@ interface ScriptActionButtonProps {
testID: string;
}
const ThemedPlay = withUnistyles(Play);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedExternalLink = withUnistyles(ExternalLink);
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const mutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const blueColorMapping = (theme: Theme) => ({
color: theme.colors.palette.blue[500],
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.palette.green[500],
});
const redColorMapping = (theme: Theme) => ({
color: theme.colors.palette.red[500],
});
const playFillTransparent = { fill: "transparent" };
interface ScriptActionButtonChildrenProps {
hovered?: boolean;
icon: ScriptActionIcon;
label: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}
function ScriptActionButtonChildren({
hovered,
icon,
label,
theme,
}: ScriptActionButtonChildrenProps): ReactElement {
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconProps = { size: 10, color };
const iconElement =
icon === "view" ? (
<ThemedSquareTerminal size={10} uniProps={colorMapping} />
<SquareTerminal {...iconProps} />
) : (
<ThemedPlay size={10} uniProps={colorMapping} {...playFillTransparent} />
<Play {...iconProps} fill="transparent" />
);
const labelStyle = hovered ? actionButtonLabelHoveredStyle : styles.actionButtonLabel;
const labelStyle = useMemo(() => [styles.actionButtonLabel, { color }], [color]);
return (
<>
{iconElement}
@@ -98,6 +77,8 @@ function ScriptActionButton({
onPress,
testID,
}: ScriptActionButtonProps): ReactElement {
const { theme } = useUnistyles();
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
@@ -108,9 +89,9 @@ function ScriptActionButton({
const renderChildren = useCallback(
({ hovered }: { hovered?: boolean }) => (
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} />
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} theme={theme} />
),
[icon, label],
[icon, label, theme],
);
return (
@@ -142,26 +123,32 @@ interface HostLinkChildrenProps {
hovered?: boolean;
disabled: boolean;
label: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}
function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): ReactElement {
function HostLinkChildren({
hovered,
disabled,
label,
theme,
}: HostLinkChildrenProps): ReactElement {
const showIcon = !disabled && (hovered || isNative);
const isActive = Boolean(hovered) && !disabled;
const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping;
const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel;
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
const hostLabelStyle = useMemo(() => [styles.hostLabel, { color }], [color]);
return (
<>
<Text style={hostLabelStyle} numberOfLines={1}>
{label}
</Text>
<View style={styles.hostIconSlot}>
{showIcon ? <ThemedExternalLink size={10} uniProps={colorMapping} /> : null}
{showIcon ? <ExternalLink size={10} color={color} /> : null}
</View>
</>
);
}
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
const { theme } = useUnistyles();
const disabled = !url;
const handlePress = useCallback(
@@ -174,9 +161,9 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
const renderChildren = useCallback(
({ hovered }: { hovered?: boolean }) => (
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} />
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} theme={theme} />
),
[disabled, label],
[disabled, label, theme],
);
return (
@@ -194,7 +181,9 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
}
function ExitCodeBadge({ code }: { code: number }): ReactElement {
const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle;
const { theme } = useUnistyles();
const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300];
const exitTextStyle = useMemo(() => [styles.exitBadgeText, { color }], [color]);
return (
<View style={styles.exitBadge}>
<Text style={exitTextStyle}>exit {code}</Text>
@@ -217,24 +206,26 @@ interface ScriptRowProps {
: null
: null;
isStartPending: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
onStartScript: (scriptName: string) => void;
onViewTerminal?: (terminalId: string) => void;
}
function resolveScriptIconColorMapping(args: {
function resolveScriptIconColor(args: {
theme: ReturnType<typeof useUnistyles>["theme"];
script: WorkspaceDescriptor["scripts"][number];
isService: boolean;
isRunning: boolean;
}): (theme: Theme) => { color: string } {
const { script, isService, isRunning } = args;
}): string {
const { theme, script, isService, isRunning } = args;
if (isService) {
if (isRunning && script.health === "healthy") return greenColorMapping;
if (isRunning && script.health === "unhealthy") return redColorMapping;
if (isRunning) return blueColorMapping;
return mutedColorMapping;
if (isRunning && script.health === "healthy") return theme.colors.palette.green[500];
if (isRunning && script.health === "unhealthy") return theme.colors.palette.red[500];
if (isRunning) return theme.colors.palette.blue[500];
return theme.colors.foregroundMuted;
}
if (isRunning) return blueColorMapping;
return mutedColorMapping;
if (isRunning) return theme.colors.palette.blue[500];
return theme.colors.foregroundMuted;
}
function ScriptRow({
@@ -242,6 +233,7 @@ function ScriptRow({
liveTerminalIdSet,
activeConnection,
isStartPending,
theme,
onStartScript,
onViewTerminal,
}: ScriptRowProps): ReactElement {
@@ -276,8 +268,9 @@ function ScriptRow({
}
}
const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning });
const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal;
const iconColor = resolveScriptIconColor({ theme, script, isService, isRunning });
const ScriptIcon = isService ? Globe : SquareTerminal;
const showExitBadge = !isRunning && exitCode !== null;
const handleView = useCallback(() => {
@@ -289,8 +282,13 @@ function ScriptRow({
}, [onStartScript, script.scriptName]);
const scriptNameStyle = useMemo(
() => (isRunning ? scriptNameActiveStyle : styles.scriptName),
[isRunning],
() => [
styles.scriptName,
{
color: isRunning ? theme.colors.foreground : theme.colors.foregroundMuted,
},
],
[isRunning, theme.colors.foreground, theme.colors.foregroundMuted],
);
let primaryAction: ReactElement | null = null;
@@ -324,7 +322,7 @@ function ScriptRow({
style={styles.scriptItem}
>
<View style={styles.scriptHeader}>
<ScriptIcon size={14} uniProps={iconColorMapping} style={styles.scriptIcon} />
<ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} />
<Text style={scriptNameStyle} numberOfLines={1}>
{script.scriptName}
</Text>
@@ -357,6 +355,7 @@ export function WorkspaceScriptsButton({
onViewTerminal,
hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null {
const { theme } = useUnistyles();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
@@ -403,7 +402,6 @@ export function WorkspaceScriptsButton({
}
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping;
return (
<View style={styles.row}>
@@ -416,9 +414,15 @@ export function WorkspaceScriptsButton({
accessibilityLabel="Workspace scripts"
>
<View style={styles.splitButtonContent}>
<ThemedPlay size={14} uniProps={triggerPlayMapping} {...playFillTransparent} />
<Play
size={14}
color={
hasAnyRunning ? theme.colors.palette.blue[500] : theme.colors.foregroundMuted
}
fill="transparent"
/>
{!hideLabels && <Text style={styles.splitButtonText}>Scripts</Text>}
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</View>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -436,6 +440,7 @@ export function WorkspaceScriptsButton({
liveTerminalIdSet={liveTerminalIdSet}
activeConnection={activeConnection}
isStartPending={startScriptMutation.isPending}
theme={theme}
onStartScript={handleStartScript}
onViewTerminal={onViewTerminal}
/>
@@ -506,10 +511,6 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 18,
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
},
scriptNameActive: {
color: theme.colors.foreground,
},
spacer: {
flex: 1,
@@ -530,10 +531,6 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
fontSize: theme.fontSize.xs,
lineHeight: 14,
color: theme.colors.foregroundMuted,
},
hostLabelActive: {
color: theme.colors.foreground,
},
hostIconSlot: {
width: 10,
@@ -552,10 +549,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: 10,
lineHeight: 12,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
exitBadgeTextError: {
color: theme.colors.palette.red[300],
},
actionButton: {
flexDirection: "row",
@@ -565,14 +558,5 @@ const styles = StyleSheet.create((theme) => ({
actionButtonLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
actionButtonLabelHovered: {
color: theme.colors.foreground,
},
}));
const actionButtonLabelHoveredStyle = [styles.actionButtonLabel, styles.actionButtonLabelHovered];
const hostLabelActiveStyle = [styles.hostLabel, styles.hostLabelActive];
const scriptNameActiveStyle = [styles.scriptName, styles.scriptNameActive];
const exitBadgeTextErrorStyle = [styles.exitBadgeText, styles.exitBadgeTextError];
@@ -1,16 +1,15 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Check } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SyncedLoader } from "@/components/synced-loader";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import type { Theme } from "@/styles/theme";
export interface WorkspaceTabPresentation {
key: string;
@@ -104,22 +103,22 @@ interface WorkspaceTabIconProps {
statusDotBorderColor?: string;
}
const ThemedCheckIcon = withUnistyles(Check);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function WorkspaceTabIcon({
presentation,
active = false,
size = 14,
statusDotBorderColor,
}: WorkspaceTabIconProps): ReactElement {
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
const bucket = presentation.statusBucket;
let statusDotColor: string | null = null;
if (bucket === "needs_input") statusDotColor = styles.statusDotNeedsInput.color;
else if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
const { theme } = useUnistyles();
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
const statusDotColor =
presentation.statusBucket === null
? null
: getStatusDotColor({
theme,
bucket: presentation.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
@@ -140,20 +139,27 @@ export function WorkspaceTabIcon({
styles.statusDot,
{
backgroundColor: statusDotColor ?? undefined,
borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor,
borderColor: statusDotBorderColor ?? theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
],
[statusDotColor, statusDotBorderColor, statusDotSize, statusDotOffset],
[statusDotColor, statusDotBorderColor, theme.colors.surface0, statusDotSize, statusDotOffset],
);
if (shouldShowLoader) {
return (
<View style={agentIconWrapperStyle}>
<SyncedLoader size={size - 1} color={styles.syncedLoader.color} />
<SyncedLoader
size={size - 1}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View>
);
}
@@ -181,6 +187,7 @@ export function WorkspaceTabOptionRow({
onPress,
trailingAccessory,
}: WorkspaceTabOptionRowProps): ReactElement {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
@@ -206,7 +213,7 @@ export function WorkspaceTabOptionRow({
</Pressable>
{selected ? (
<View style={styles.optionTrailingSlot}>
<ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
{trailingAccessory ? (
@@ -231,33 +238,6 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
statusDotBorderDefault: {
borderColor: theme.colors.surface0,
},
statusDotNeedsInput: {
color: theme.colors.palette.amber[500],
},
statusDotFailed: {
color: theme.colors.palette.red[500],
},
statusDotRunning: {
color: theme.colors.palette.blue[500],
},
statusDotAttention: {
color: theme.colors.palette.green[500],
},
iconActive: {
color: theme.colors.foreground,
},
iconInactive: {
color: theme.colors.foregroundMuted,
},
syncedLoader: {
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
},
optionRow: {
flexDirection: "row",
alignItems: "center",
@@ -1,28 +1,9 @@
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: {
@@ -94,10 +75,6 @@ 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(() => {
@@ -196,58 +173,4 @@ 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,4 +1,3 @@
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 {
@@ -39,13 +38,8 @@ 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>();
@@ -60,87 +54,13 @@ 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;
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();
for (const listener of listeners) {
listener();
}
}
@@ -250,7 +170,7 @@ export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverR
const route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
setLastWorkspaceRouteSelection(routeState.selection);
lastWorkspaceRouteSelection = routeState.selection;
if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null;
@@ -277,22 +197,6 @@ 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;
}
@@ -319,14 +223,6 @@ 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;
@@ -347,8 +243,6 @@ export function useIsNavigationWorkspaceSelected(input: {
);
}
void hydrateLastNavigationWorkspaceRouteSelection();
export function useIsNavigationProjectActive(input: {
serverId: string | null;
workspaceIds: readonly string[];
@@ -115,50 +115,6 @@ 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,9 +119,6 @@ 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(
@@ -139,9 +136,6 @@ 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,
};
}
+69 -84
View File
@@ -164,7 +164,7 @@ const lightSemanticColors = {
accentForeground: "#ffffff",
// Semantic
destructive: "#b04138", // dark warm red on white — calm but unambiguously red
destructive: "#dc2626",
destructiveForeground: "#ffffff",
success: "#20744A",
successForeground: "#ffffff",
@@ -233,7 +233,6 @@ interface DarkThemeConfig {
borderAccent: string;
accent: string;
accentBright: string;
destructive: string;
}
const darkTerminalAnsi = {
@@ -277,7 +276,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
accentBright: tint.accentBright,
accentForeground: "#ffffff",
destructive: tint.destructive,
destructive: "#ef4444",
destructiveForeground: "#ffffff",
success: tint.accent,
successForeground: "#ffffff",
@@ -333,7 +332,6 @@ const paseoDarkColors = buildDarkSemanticColors({
borderAccent: "#2F3534",
accent: "#20744A",
accentBright: "#7ccba0",
destructive: "#c64f43", // warm red, hue ~7 — reads as red (not pink) against the green tint
});
// Zinc — neutral gray, no tint
@@ -352,7 +350,6 @@ const zincDarkColors = buildDarkSemanticColors({
borderAccent: "#303036",
accent: "#20744A",
accentBright: "#7ccba0",
destructive: "#c44a4a", // neutral red, hue 0 — clearly red without screaming
});
// Midnight — subtle blue tint
@@ -371,7 +368,6 @@ const midnightDarkColors = buildDarkSemanticColors({
borderAccent: "#2e3040",
accent: "#3b6fcf",
accentBright: "#7eaaeb",
destructive: "#c44a52", // red with a hint of cool lean against the blue tint
});
// Claude — warm neutral with subtle orange undertone
@@ -390,7 +386,6 @@ const claudeDarkColors = buildDarkSemanticColors({
borderAccent: "#36332f",
accent: "#d97757",
accentBright: "#e89a7f",
destructive: "#cf513e", // warm orange-red, hue ~10 — sits with the Claude orange accent
});
// Ghostty — blue-tinted dark based on Ghostty default background
@@ -409,86 +404,76 @@ const ghosttyDarkColors = buildDarkSemanticColors({
borderAccent: "#3f4454",
accent: "#89b4fa",
accentBright: "#b4d0fc",
destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces
});
export const SPACING = {
0: 0,
1: 4,
1.5: 6,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
20: 80,
24: 96,
32: 128,
} as const;
export const FONT_SIZE = {
xs: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
"2xl": 22,
"3xl": 26,
"4xl": 34,
} as const;
export const LINE_HEIGHT = {
diff: 22,
} as const;
export const ICON_SIZE = {
xs: 12,
sm: 14,
md: 16,
lg: 20,
} as const;
export const FONT_WEIGHT = {
normal: "normal" as const,
medium: "500" as const,
semibold: "600" as const,
bold: "bold" as const,
} as const;
export const BORDER_RADIUS = {
none: 0,
sm: 2,
base: 4,
md: 6,
lg: 8,
xl: 12,
"2xl": 16,
full: 9999,
} as const;
export const BORDER_WIDTH = {
0: 0,
1: 1,
2: 2,
} as const;
export const OPACITY = {
0: 0,
50: 0.5,
100: 1,
} as const;
const commonTheme = {
spacing: SPACING,
fontSize: FONT_SIZE,
lineHeight: LINE_HEIGHT,
iconSize: ICON_SIZE,
fontWeight: FONT_WEIGHT,
borderRadius: BORDER_RADIUS,
borderWidth: BORDER_WIDTH,
opacity: OPACITY,
spacing: {
0: 0,
1: 4,
1.5: 6,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
20: 80,
24: 96,
32: 128,
},
fontSize: {
xs: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
"2xl": 22,
"3xl": 26,
"4xl": 34,
},
lineHeight: {
diff: 22,
},
iconSize: {
xs: 12,
sm: 14,
md: 16,
lg: 20,
},
fontWeight: {
normal: "normal" as const,
medium: "500" as const,
semibold: "600" as const,
bold: "bold" as const,
},
borderRadius: {
none: 0,
sm: 2,
base: 4,
md: 6,
lg: 8,
xl: 12,
"2xl": 16,
full: 9999,
},
borderWidth: {
0: 0,
1: 1,
2: 2,
},
opacity: {
0: 0,
50: 0.5,
100: 1,
},
} as const;
const darkShadow = {
@@ -24,7 +24,6 @@ interface MountedTerminal {
host: HTMLDivElement;
root: HTMLDivElement;
runtime: TerminalEmulatorRuntime;
inputs: string[];
sizes: TerminalSize[];
}
@@ -66,13 +65,9 @@ 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);
},
@@ -89,7 +84,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
},
});
const mounted = { host, root, runtime, inputs, sizes };
const mounted = { host, root, runtime, sizes };
mountedTerminals.push(mounted);
return mounted;
}
@@ -162,29 +157,6 @@ describe("terminal emulator runtime in a real browser", () => {
expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]);
});
it.each([
{ name: "DA1", bytes: "\x1b[c" },
{ name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" },
{ name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
])("does not emit a PTY input reply for $name", async ({ bytes }) => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.write({ text: bytes });
await nextFrame();
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 });
@@ -220,26 +220,6 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize?.(true);
};
// Browser xterm is a renderer only; it never replies to terminal protocol queries.
// Replies live on the daemon (one process boundary from the PTY) so they arrive
// before the foreground app exits, instead of racing back over the websocket.
// Re-registered after the image addon loads so our handlers stay last in the
// LIFO dispatch (the image addon registers its own {final:"c"} for sixel DA1).
const registerProtocolQuerySuppression = (): void => {
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
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,
);
};
registerProtocolQuerySuppression();
let webglAddonRaf: number | null = requestAnimationFrame(() => {
webglAddonRaf = null;
try {
@@ -251,13 +231,18 @@ export class TerminalEmulatorRuntime {
terminal.loadAddon(webglAddon);
imageAddon = new ImageAddon();
terminal.loadAddon(imageAddon);
registerProtocolQuerySuppression();
this.fitAndEmitResize?.(true);
} catch {
disposeWebglRenderer();
}
});
// 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);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
});
@@ -1,42 +0,0 @@
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();
});
});
@@ -1,70 +0,0 @@
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,8 +4,6 @@ import {
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment,
@@ -135,28 +133,3 @@ 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,18 +403,6 @@ 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) {
@@ -1,236 +0,0 @@
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"]);
});
});
@@ -1,187 +0,0 @@
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
@@ -1,465 +0,0 @@
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
@@ -1,193 +0,0 @@
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,7 +13,6 @@ 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,13 +11,11 @@ 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",

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