Compare commits

...
Author SHA1 Message Date
Mohamed Boudra bcd32962f7 sidebar history: post-merge fixes (header, kebab, +) and deeper abstractions
Three behavior fixes from user feedback after the parity pass landed.

R1 — Non-git project headers were missing in grouped Sessions while their
child rows still rendered. Sessions had inherited the workspace tree's
isSidebarProjectFlattened branch, but only half of it: the header was
suppressed and the children continued. Sessions doesn't share the
workspace tree's flatten semantics — workspaces flatten because
"1 workspace = the project IS the workspace, no children to expand";
in Sessions, even a single-workspace non-git project can still have
multiple agents worth grouping. Drop the flatten branch from Sessions
entirely. SidebarSessionGroup loses isFlattened. Workspace tree's
FlattenedProjectRow stays untouched.

R2 — The agent kebab had Close + Archive. Sessions agent rows aren't
tabs; there's no open tab to close. Drop Close and the orphan chain
that came with it (workspace-tab menu imports, session store reach,
confirm dialog wiring, resolveSidebarSessionWorkspaceId, etc.). The
menu is now Archive-only; useArchiveAgent is the only handler.

R3 — Each project header in grouped Sessions now renders a "+" that
opens the new-workspace flow scoped to that project's directory. The
shared SidebarProjectTrailingActions is the home — Sessions wires its
own createButton config (Plus icon, "New session", route via
buildHostNewWorkspaceRoute(serverId, sourceDirectory, headerTitle)).

R4 — Meta-audit on the shared sidebar/* abstractions found two depth
issues that let R1 and R3 silently slip through:

1. SidebarProjectHeaderRow was a chrome primitive. Consumers could
   render children without rendering a header (R1 bug). Added
   SidebarProjectSection — an opinionated wrapper that owns
   "header + children/footer when expanded" so the silent failure
   mode no longer compiles. Both consumers refactored.

2. SidebarProjectTrailingActions had showNewWorktreeButton: boolean
   defaulting false. Sessions silently lost the affordance with no
   warning (R3 bug). Replaced the boolean with a required createButton
   prop (literal null to opt out). Each consumer must say what it
   wants — no default suppression.

Surfaced for future iterations (not fixed here): hand-wired hover
state in both consumers, chevron native visibility, missing
project/agent selection state in Sessions, zero-agent-project filter
divergence.

Tests cover the new wrapper, the trailing-actions API change, the
header-always-renders behavior, and the per-project + button
navigation. Workspace tree behavior unchanged.
2026-05-08 23:04:27 +07:00
Mohamed Boudra d00bd666a7 sidebar history: parity with workspace tree (ordering, drag, kebabs, +)
Stops reinventing UX the workspace tree already solved. Five
behavior-preserving extractions plus a few wires.

Project ordering parity:
Sessions consumes the canonical project list from useSidebarWorkspaces
List instead of computing first-occurrence order from agents. The
workspace tree's compareWorkspaceStructureProjects + persisted manual
order via useSidebarOrderStore is the single source of truth for both
views. Single non-git workspace projects flatten (no header, no
chevron, rows render top-level) via the existing isSidebarProjectFlat
tened gate from sidebar-project-row-model. Multi-workspace non-git
projects and git projects collapse normally.

Drag and drop parity:
New SidebarProjectsDraggableList wraps the existing DraggableList,
useDragHandle, useLongPressDragInteraction (extracted to its own file
for sharing), and persists via setProjectOrder + the existing
hasVisibleOrderChanged / mergeWithRemainder helpers. Both the
workspace tree and grouped Sessions consume it. Workspace tree
behavior unchanged; sessions's grouped view is now structured as a
NestableScrollContainer / ScrollView around per-project sections.

Project trailing actions parity:
New SidebarProjectTrailingActions owns the project kebab (settings +
remove), optional new-worktree button, and shortcut hint. Workspace
tree uses it with the new-worktree button enabled; grouped Sessions
uses it with kebab only.

Per-agent kebab:
New SidebarSessionRowKebabMenu reuses useArchiveAgent for archive,
mirrors handleCloseAgentTab semantics for close, pulls the close
menu entry from buildWorkspaceTabMenuEntries, and matches the
workspace row kebab visibility (hover or touch). No fork.

New session button:
Plus button in the Sessions header reuses useOpenProjectPicker and
buildHostNewWorkspaceRoute. Adds a label-only `title` query arg so
the destination screen reads "New session" instead of "New workspace"
when entered from this button.

Tests cover ordering, flatten/collapse, drag-end persistence, kebab
visibility/items, and the new-session route. Render-boundary tests
extended for the section structure.
2026-05-08 22:03:47 +07:00
Mohamed Boudra 4f047832ac sidebar history: collapsible project sections, indented children
The grouped Sessions view now matches the workspace tree's project-row
UX instead of forking it. Both consumers share one composable.

New: SidebarProjectHeaderRow (memoized, slot-driven) — chevron,
hover/press/selected backgrounds, leading visual override, trailing
slot for kebab/shortcut/new-worktree, optional PressableComponent for
ContextMenuTrigger. Plus sidebarProjectChildIndentStyles for indenting
children under the header.

Workspace tree refactored to consume it: drag handle, status icon,
shortcut badge, kebab menu, and ContextMenuTrigger all flow through
slots. Behavior preserved.

Grouped Sessions:
- Header now collapses on press, with the same chevron-on-hover
  affordance the workspace tree has.
- Section collapse uses the same persistent useSidebarCollapsedSections
  store the workspace tree uses, so collapsing Project A in either
  view collapses it in both.
- Children rows get indented: SidebarSessionRow accepts an `indented`
  prop wired to the shared style. Footer matches.
- Show-more vs section-collapse are now distinct concepts:
  controller's expandedProjects renamed to previewExpandedProjects,
  toggleProjectExpanded -> toggleProjectPreviewExpanded. Collapse
  beats preview-expansion.

deriveGroupedSidebarSessions takes both preview-expanded and
collapsed sets. Collapsed groups return empty visibleIds and the
flattener emits header-only for them.

Tests: 41 passing across 7 files. New cases cover collapsed-hides-rows,
collapsed-wins-over-preview-expanded, and the section-only flatten
emission.
2026-05-08 20:45:18 +07:00
Mohamed Boudra a3c3d25592 sidebar history: group by project toggle with show more/less
Adds a group-by-project toggle to the Sessions header. When ON, the
flat session list becomes a grouped list — one section per project,
preview-capped at 6 agents per project with "Show N more" / "Show less"
controls per group. When OFF, the flat list stays as before.

Architecture preserves the Phase 7b perf model:

- Controller (useSidebarSessionsController) owns groupByProject and
  expandedProjects state. Expansion resets on grouping-off, host change,
  and filter change. Group-by persists across hosts as a UI preference.

- A new useOrderedAgentProjectShape selector reactively subscribes to
  per-agent cwd via useStoreWithEqualityFn(..., shallow). When project
  assignments don't change, the array reference stays stable; when an
  agent's project changes, the parent re-derives groups. Shape objects
  are reused across renders when project fields are unchanged.

- deriveGroupedSidebarSessions takes the resolved shape directly (no
  callback indirection) and returns ordered groups with visible/hidden
  ids based on per-project expansion state.

- Header and Footer components are React.memo'd with primitive props
  only, so they don't re-render on irrelevant agent updates. Per-row
  status indicators continue to subscribe via the shared tab descriptor
  path, isolated from parent renders.

- Project ordering: first-occurrence in the createdAt-desc id list,
  matching the user's mental model of "most-recent project first" and
  avoiding a per-render activity-timestamp computation.

Visual primitive shared between the workspace tree project row and the
new Sessions group header (packages/app/src/components/sidebar/
sidebar-project-row-visual.tsx) — no two systems for project visuals.
The workspace tree wraps it with its own chevron/status; the Sessions
header uses it bare.

Tests added: state-machine cases on the controller, pure-helper coverage
on grouping derivation, render-boundary stability for grouped data
references across irrelevant updates and reactivity on project changes,
and a footer click test.
2026-05-08 20:14:38 +07:00
Mohamed Boudra b252cd98d2 sidebar history: status indicator on session rows
Sidebar Session rows now mirror the workspace tab descriptor: same
provider icon, same loader spinner, same status dot, same attention
badge. Reuses WorkspaceTabPresentationResolver + WorkspaceTabIcon
directly — no parallel implementation. Status reactivity stays inside
the tab descriptor's own per-agent selector, so selectSidebarSessionSlice
is unchanged and the parent ids hook keeps its reference stability.

Navigation test mocks tab presentation so it doesn't pull the full
workspace stack just to assert click behavior; production uses the
real shared component.
2026-05-08 19:25:20 +07:00
Mohamed Boudra 670e6a6fdd sidebar history: drop workspace filter, project-only
Removes the Workspace section from the Sessions filter dropdown.
Final dropdown is All + Project. Simplifies the filter discriminated
union (drops the "workspace" variant), the predicate, and the
hook-derived option list. Workspace mapping is still used internally
to map session cwd to project for the Project filter and for hiding
unmapped agents.

E2E journey simplified — the workspace-filter step is gone, including
its brittle accessible-name regex.
2026-05-08 18:31:35 +07:00
Mohamed Boudra e9c3753466 sidebar history: design revamp, branch names in filter, splash timeout
Rolls up three follow-ups on the sidebar Workspaces/Sessions toggle:

Design revamp (Notion-style toggle, History at top):
- Toggle pills both carry icons (Folders/MessagesSquare); only the
  active pill shows its label, with LinearTransition + FadeIn/FadeOut.
- Filter trigger now uses lucide ListFilter; History row uses lucide
  History (clock); History row moved to the top of the sidebar.
- Session row content trimmed to icon + name + timestamp (no cwd
  path); padding/spacing/type ramp matches the existing Workspaces
  tree rows so the icon and text columns align across History row,
  toggle pills, and list rows at the canonical 16px column.
- Filter dropdown uses sentence-case section labels (Workspace/
  Project) and renders project icons (no text-letter fallbacks).
- testID rename: sidebar-sessions -> sidebar-history (the navigation
  row); new toggle pills get sidebar-toggle-workspaces /
  sidebar-toggle-sessions. Updated callers in helpers and maestro.
- No bold or font-weight changes; no uppercase anywhere.

Branch names in workspace filter:
- The dropdown's Workspaces section was rendering the unhydrated
  structural workspace.name (often a path-like id) because the
  selector chain skipped the rehydration step the main sidebar row
  uses. The hook now reuses createSidebarWorkspaceEntry so the label
  comes from the same descriptor path WorkspaceRow uses, matching
  what the user sees in the main sidebar.

Splash screen timeout for env-var local daemon:
- The 5s give-up timer in _layout.tsx had an explicit early-return
  exempting waitForConfiguredLocalDaemon, leaving the splash hung
  forever when EXPO_PUBLIC_LOCAL_DAEMON pointed at an offline
  daemon. Removed the exemption so the timer applies; the runtime
  override loop continues retrying in the background and welcome
  auto-redirects when any host comes online.
2026-05-08 17:58:50 +07:00
Mohamed Boudra 9c1bea7135 feat(app): sidebar Workspaces/Sessions toggle with History rename
Adds a Notion-style pill toggle at the top of the always-visible sidebar
that switches between Workspaces (current tree) and Sessions (flat
live-updating list of agents across the daemon). Sessions are sorted by
createdAt desc (stable, tie-broken by id) and filtered to agents whose
cwd maps to an opened workspace; clicking a row focuses the matching
workspace tab via prepareWorkspaceTab + navigateToPreparedWorkspaceTab.
The existing "Sessions" sidebar row is renamed to "History" (same
destination).

Lives in its own module at packages/app/src/sidebar-sessions/ with the
sidebar shell pieces extracted to packages/app/src/components/sidebar/.
Render performance is treated as first-class: parent subscribes to an
ordered id list with shallow array equality (useAggregatedAgentIds),
each row uses a per-agent shallow Zustand selector, the inactive view
is unmounted on toggle, and the filter dropdown subscription is gated
to Sessions mode. Filter state resets on host change. Agents whose cwd
doesn't map to an opened workspace are hidden from both list and
dropdown options.

Includes a Playwright user-journey spec and unit/integration tests for
the new ids hook, slice selector, render boundaries, navigation,
filtering, and the sidebar-sessions controller.
2026-05-08 17:58:50 +07:00
Mohamed Boudra e490bf1dab fix(server-tests): unblock Windows CI for Claude SDK + .exe launch test
- spawn.launch-regression: insert `--` between `node -e <body>` and the
  user JSON args. Without it Node treats `--config` as a CLI option and
  exits with code 9 ("bad option"). The `--` stops Node's flag parsing
  so userArgs land in process.argv intact.
- claude-agent.integration + claude-sdk-behavior: inject
  spawnClaudeCodeProcess that routes the SDK's launch through our
  spawnProcess (shell: false). On Windows the SDK's default spawn fails
  with EINVAL when pathToClaudeCodeExecutable resolves to a `.cmd` shim
  (CVE-2024-27980). This mirrors what claude-agent.ts does in production.
- 3 cleanup sites (event-stream, integration, sdk-behavior): swallow
  EBUSY/ENOTEMPTY/EPERM around rmSync. The maxRetries window doesn't
  reliably win the Windows cwd-lock race after Claude exits; the OS will
  reap the tmpdir.
2026-05-08 17:43:15 +07:00
Mohamed Boudra b131876e69 test(server): cover .exe/.cmd/.bat end-to-end launch on Windows
Add three end-to-end tests in spawn.launch-regression.test.ts that exercise
findExecutable -> spawnProcess for each Windows-supported extension. Each
test creates a uniquely-named binary in a tempdir, prepends it to PATH,
verifies findExecutable returns the fixture path, then launches via
spawnProcess and asserts an exact JSON-arg roundtrip plus stdout/exit code.

The previous "does not detect a PowerShell shim from PATH" test is removed:
its expectation was satisfied via PATH-prepend (not isolation), so global
claude.CMD on CI made it return non-null. The supported-extension contract
matches which's hardcoded fallback (.EXE;.CMD;.BAT;.COM); .ps1 is not
supported by spawnProcess and not surfaced by which without explicit PATHEXT.

Also add maxRetries: 5 / retryDelay: 100 to rmSync cleanups in three Claude
integration tests to absorb the brief Windows cwd lock after Claude exits.
2026-05-08 17:24:52 +07:00
Mohamed Boudra b7d2e6d3ec Revert "ci: gate Claude Code install to Linux server-tests"
This reverts commit 09ed46b997.
2026-05-08 16:28:49 +07:00
Mohamed Boudra 09ed46b997 ci: gate Claude Code install to Linux server-tests
Installing claude-code globally on the Windows server-tests runner
exposed 12 pre-existing Windows-specific test failures that were
silently skipped before (rmdir EBUSY during cleanup, .CMD spawn
EINVAL via the SDK, plus the launch-regression test that asserts
claude is *not* on PATH).

The Linux e2e tests (model-catalog, live-preferences, models) are
the only ones that needed the binary to flip isAvailable() true;
none of those run on Windows. Skip the install on Windows so those
Windows compatibility gaps stay surfaced separately and don't block
the SDK upgrade.
2026-05-08 16:26:43 +07:00
Mohamed Boudra a4d365c8f2 fix server-ci: install only claude-code, set SDK path in contract test
Two regressions from the previous CI install:

- Installing opencode-ai globally in server-ci/server-tests caused
  opencode-agent.test.ts (gated on \`hasOpenCode\`) to start running
  for the first time and surface real but unrelated test breakage.
  Trim the install to claude-code, which is all that's needed for the
  Claude e2e tests that motivated the change.

- claude-agent.integration.test.ts's supportedModels test calls the
  SDK's \`query()\` directly (bypassing ClaudeAgentClient) and didn't
  pass \`pathToClaudeCodeExecutable\`. The 0.2.71 SDK had a bundled
  cli.js fallback; 0.2.133 doesn't, so on Ubuntu CI it tries the
  per-platform binary path (\`...claude-agent-sdk-linux-x64-musl/claude\`)
  which isn't installed via the \`claude-code\` global. Resolve the
  binary explicitly in the test and pass it as the SDK option.
2026-05-08 16:14:28 +07:00
Mohamed Boudra 13538dd710 ci: install claude-code globally in server-tests
server-tests in ci.yml and Server CI both need claude on PATH for the
e2e tests that probe model catalogs and live preferences. cli-tests
already installs the trio (claude-code, codex, opencode-ai); server
tests now match.
2026-05-08 16:05:30 +07:00
Mohamed Boudra e60350be08 refactor(claude-agent): inject binary resolver via client constructor
The session previously called resolveClaudeBinary() directly in
buildOptions(), with no seam for tests. CI ran without claude on PATH,
so every test that exercised buildOptions hit the production throw.

Apply the ports-and-adapters pattern at the client boundary (the same
place tests already configure queryFactory):

- ClaudeAgentClient takes optional resolveBinary, defaults to
  resolveClaudeBinary which throws "Claude binary not found..." when
  the executable is missing — preserving production fail-fast.
- Client passes resolveBinary through to ClaudeAgentSession options.
- buildOptions calls this.resolveBinary() instead of importing the
  free function directly.
- Tests construct the public client with a stub resolver. No
  __claudeAgentInternals export, no reaching into the session.

User-facing fail-fast still lives where it always did — AgentManager
gates start/resume/draft flows on client.isAvailable(). The throw in
resolveClaudeBinary is defense-in-depth that mirrors Codex.
2026-05-08 15:55:14 +07:00
Mohamed Boudra b0c36f2bab test: Claude isAvailable now reflects PATH binary, no SDK fallback 2026-05-08 15:28:46 +07:00
Mohamed Boudra d708f099be fix(claude-agent): don't fail-fast in buildOptions
resolveClaudeBinary threw eagerly inside buildOptions, breaking unit tests
that mock the SDK at the module level (env, redesign, interrupt-restart-
regression suites). The user-facing fail-fast already lives in isAvailable();
in buildOptions, fall back to omitting pathToClaudeCodeExecutable so the SDK
surfaces its own error if the binary truly is missing.
2026-05-08 15:25:57 +07:00
Mohamed Boudra cb2fe91998 fix CI fallout from claude-agent-sdk 0.2.133 upgrade
- mcp-server tests: MCP SDK 1.29 renamed RegisteredTool.callback → handler
- ci: install @anthropic-ai/claude-code globally for cli-tests, matching
  codex/opencode now that bundled fallback is gone
2026-05-08 15:18:24 +07:00
github-actions[bot] d7820b7a84 fix: update lockfile signatures and Nix hash 2026-05-08 08:08:58 +00:00
Mohamed Boudra fb0fbb79f6 upgrade claude-agent-sdk to 0.2.133, drop bundled binary
Bumps @anthropic-ai/claude-agent-sdk from ^0.2.71 to ^0.2.133. The new SDK
hardens handleControlRequest against post-close transport writes (v0.2.94+),
which fixes the unhandledRejection that crashed the daemon when the SDK fired
a fire-and-forget control_request after we'd torn down the transport.

The SDK now ships per-platform Claude Code binaries via optionalDependencies
(~210 MB each). Paseo requires user-installed `claude` on PATH instead — same
posture as Codex/OpenCode:

- after-pack now prunes @anthropic-ai/claude-agent-sdk-* from the Electron
  bundle, matching the existing per-platform pruning for onnxruntime, sharp,
  and node-pty.
- claude-agent.ts gains resolveClaudeBinary() (mirrors resolveCodexBinary):
  throws with install instructions when `claude` is missing, instead of
  silently falling back to the bundled binary.
- isAvailable() now checks isCommandAvailable("claude") instead of always
  returning true.
2026-05-08 15:07:40 +07:00
Mohamed Boudra c97c000d9c paseo-epic: lock requirements early and gate on them
Adds a pre-start preferences step (onboards users with no
orchestration-preferences.json), a capture-intent step that runs
even under --autopilot, an immutable Requirements block in the plan
that includes acceptance criteria, and a final lightweight check
that audits every requirement before deliver. Drops --no-grill.
2026-05-08 11:46:45 +07:00
Mohamed Boudra c4e4a28bc0 Run server tests on Linux+Windows matrix (#809)
* Run server tests on Linux+Windows matrix

Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.

* Add isPlatform helper and gate Windows-hostile server tests

- Add a shared server test isPlatform helper.

- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.

- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.

* Replace POSIX shell calls in test fixtures with Node primitives

- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.

- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.

- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.

* Fix Windows server-test failures and split POSIX-only suites into sibling files

- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.

* Move POSIX-only test blocks into sibling .posix.test.ts files

- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.

- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.

- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.

- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.

- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.

* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests

- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.

- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.

- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.

- Gate the two explicitly Linux-only workspace-git watcher tests.

* Replace hardcoded POSIX paths with portable Node path constructions in server tests

- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.

- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.

- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.

- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.

* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows

- Run the loop-service verify script through the current Node executable with a relative script path.

- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.

* Fix loop-service worker PTY spawn on Windows

- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.

* Gate loop-service real-worker-PTY test on Windows

- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.

* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows

- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.

* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir

- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
2026-05-08 10:57:22 +08:00
Mohamed Boudra 39e461b872 perf(website): render hero text immediately 2026-05-07 23:38:32 +07:00
Mohamed Boudra 734e15e5a3 perf(website): cache GitHub release metadata in KV 2026-05-07 23:22:48 +07:00
Mohamed Boudra 90bf6571d6 Revert "perf(website): avoid blocking root render on GitHub"
This reverts commit 89a500cd3e.
2026-05-07 23:10:26 +07:00
Mohamed Boudra 90fe71bd54 chore: ignore generated TanStack files 2026-05-07 23:06:48 +07:00
Mohamed Boudra 89a500cd3e perf(website): avoid blocking root render on GitHub 2026-05-07 23:06:27 +07:00
Mohamed Boudra a1ac402154 docs(website): add provider pages and refresh docs 2026-05-07 22:52:26 +07:00
Mohamed Boudra f7eac82593 fix(website): smooth docs navigation and improve lighthouse 2026-05-07 22:42:39 +07:00
github-actions[bot] 4a9c2450b7 fix: update lockfile signatures and Nix hash 2026-05-07 15:19:24 +00:00
Mohamed Boudra 27f33be0e7 docs: update orchestration docs 2026-05-07 22:17:55 +07:00
Mohamed Boudra 4c11f7c40b test(cli): add Sonnet 4.6 1M to provider models expectation
CLI provider e2e test (15-provider.test.ts) pinned the claude catalog
size; PR #799 added claude-sonnet-4-6[1m] but did not update this list.
Sibling fix to 50405c3b.
2026-05-07 20:04:52 +07:00
Mohamed Boudra 50405c3b6a test(server): add Sonnet 4.6 1M to claude-agent listModels expectation
PR #799 added claude-sonnet-4-6[1m] to the model catalog and updated
claude-models.test.ts but missed the duplicate assertion in
claude-agent.test.ts.
2026-05-07 19:53:02 +07:00
github-actions[bot] 8406559150 fix: update lockfile signatures and Nix hash 2026-05-07 12:41:26 +00:00
Mohamed Boudra f92a296ce0 chore(release): cut 0.1.70-beta.1 2026-05-07 19:40:10 +07:00
Mohamed Boudra 6786024333 docs(changelog): draft 0.1.70-beta.1 entry 2026-05-07 19:39:22 +07:00
Mohamed Boudra 56855dd6fd fix(server): keep requested cwd when creating an agent (#808)
Workspace lookup was rewriting the agent cwd to the parent workspace cwd. Pass the requested cwd through faithfully and still attach workspaceId for grouping.

Fixes #551
2026-05-07 20:28:57 +08:00
Mohamed Boudra 00a5b27586 Show opencode auth list in OpenCode diagnostic
Helps diagnose missing subscription models — surfaces which providers
the daemon-spawned opencode sees as authenticated.
2026-05-07 19:20:17 +07:00
Christoph Heer 73ed98c623 Add Sonnet 4.6 1M model to Claude model picker (#799)
Closes #334
2026-05-07 20:14:25 +08:00
Mohamed Boudra 927309e867 test(server): real-fs coverage for Linux walker gitignore skip (#806)
Adds a primitive test that initialises a real git repo, writes a
.gitignore covering an ignored subtree, and asserts the Linux working
tree walker registers watchers for kept directories but not for
gitignored ones. Exercises the real `git ls-files -o -i --directory
--exclude-standard` shell-out the new code path depends on.
2026-05-07 19:53:24 +08:00
Mohamed Boudra 2d2ee02ce3 Gate workspace open targets by daemon locality 2026-05-07 18:38:14 +07:00
Mohamed Boudra 4d4fbf7257 Drop dead test for removed Update installed branch
The "shows only the changelog action once the update is installed"
case in update-callout-source.test.tsx pinned the rendering path that
2d9c7747 deleted. The callout now early-returns for any status other
than available/installing/error, so the assertion that the container
contains "Update installed" can never be satisfied.
2026-05-07 18:35:44 +07:00
Mohamed Boudra 67c93dba49 Reorder settings sidebar: Projects after General 2026-05-07 18:27:11 +07:00
Mohamed Boudra 2d9c7747fb Fix misleading 'Update installed' callout flash
The install flow briefly flashed an "Update installed / Restart to use
the new version" callout between clicking Install & restart and the
actual app quit. The button implies action; the restart is already in
flight.

Run quitAndInstall inline (no 1.5s setTimeout) so the IPC sequences
download → install → daemon stop → app quit before returning. Drop the
"installed" rendering branch from the callout so the success path goes
straight from "Installing..." to the window unmounting.
2026-05-07 18:20:22 +07:00
xuzheandxuzhe.7766 4fa1db8d06 fix(server): stop Linux watcher event storms on busy working trees (#794)
On Linux fs.watch isn't recursive, so each working tree manually walks
the entire subtree and registers a per-directory watcher. Every inotify
event then re-scans the tree and rebuilds watchers. A working tree
containing a directory that's continuously written (e.g. test-runtime
data, build artefacts) drives the event loop to 100% CPU and the
daemon stops responding on its websocket.

Skip git-ignored directories when walking — load `git ls-files -o -i
--directory --exclude-standard` once per root (5 min cache) and skip
its entries. Cap the walked directory count at 5000 so a misconfigured
repo can't blow past the inotify budget. Add a 2s cooldown between
refresh passes so a flurry of events collapses into one re-scan
instead of a tight loop.

Co-authored-by: xuzhe.7766 <xuzhe.7766@bytedance.com>
2026-05-07 19:17:33 +08:00
Mohamed Boudra 92be6c0cba Replace skills auto-sync with explicit install/update/uninstall (#797)
* Replace skills auto-sync with explicit install/update/uninstall

Skills no longer sync silently on every desktop launch. Settings now
shows Install (fresh), Update (with a confirm dialog listing per-skill
add/update/delete ops), or Uninstall depending on what's on disk.

A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned
skills so uninstall removes only what we wrote, never user content.
Existing users migrate transparently — first read synthesises a
manifest from on-disk hashes, so an unchanged install flips silently
to up-to-date.

Also restructures the integrations layer: skills/ and cli-install/
each own their files behind a designed index.ts. The shared
integrations-manager.ts is gone.

* Rewrite skills install flow with disk-truth model

Drops the manifest sidecar, migration logic, and the
agentsDirHasSkillContent heuristic that misread user-authored skills
as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES
list (current bundle + paseo-chat for retired-skill cleanup); the
disk is the source of truth for what's installed.

Renames the SkillsState "fresh" → "not-installed" everywhere
(server, IPC parser, hook test mocks). Install and update share an
applySkills body. Errors propagate instead of being swallowed.

Fixes the case where Uninstall left state at "drift" because the
user had personal skills under ~/.agents/skills/.

* Stop symlinking claude skills, write real files

If the user has ~/.claude/skills symlinked to ~/.agents/skills, the
per-skill symlink path resolved through the parent symlink and fs.rm'd
the agents-side files we'd just written. Mirror real files to claude
the same way as codex; agents stays the canonical source-of-truth dir.

* Make Uninstall button visible (outline, not ghost)
2026-05-07 18:41:13 +08:00
Ethan Greenfeld 4f0b264886 Fix ACP terminal shell command spawning (#793) 2026-05-07 16:24:30 +08:00
Mohamed Boudra bad304c2d3 Detect GitHub issue/PR URL in composer search
Pasting a GitHub issue or pull request URL into the composer's
issue/PR picker now searches by the number from the URL instead
of treating the full URL as a keyword that returns no matches.
2026-05-07 15:23:45 +07:00
Mohamed Boudra 0785ee31f0 Match model description in combobox search
Search now matches across model label, id, provider, and description
with multi-token fuzzy matching, so "kimi zen" finds the OpenCode Zen
Kimi model.
2026-05-07 15:23:16 +07:00
236 changed files with 17912 additions and 7741 deletions
+11 -47
View File
@@ -66,7 +66,12 @@ jobs:
run: npm run typecheck
server-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
steps:
- uses: actions/checkout@v4
with:
@@ -83,6 +88,9 @@ jobs:
- name: Install dependencies
run: npm install
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -95,50 +103,6 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: >
npx vitest run
src/utils/executable.probe.test.ts
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.percent-escape.test.ts
src/utils/spawn.test.ts
src/utils/tree-kill.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/terminal/worker-terminal-manager.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
desktop-tests:
strategy:
fail-fast: false
@@ -216,7 +180,7 @@ jobs:
run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
@@ -271,7 +235,7 @@ jobs:
run: npm install
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
+3
View File
@@ -36,6 +36,9 @@ jobs:
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
+1
View File
@@ -66,6 +66,7 @@ CLAUDE.local.md
.paseo/
.wrangler/
**/.wrangler/
**/.tanstack/
# Local agent/tooling artifacts (do not commit)
PLAN.md
+1 -1
View File
@@ -11,5 +11,5 @@
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock"]
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
}
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## 0.1.70-beta.1 - 2026-05-07
### Added
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
- Detect GitHub issue and PR URLs pasted into the composer search.
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
- `paseo schedule update` to edit a schedule in place without recreating it.
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
### Improved
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
- Self-hosted relays can opt into `wss://` for TLS connections.
- Workspace open targets only show options reachable from the current daemon.
- Combobox search matches model descriptions, not just names.
- Codex image attachments render inline as path markdown.
- Subagent task notifications no longer clutter the parent agent's timeline.
- Voice mode: quieter thinking tone and small UI polish.
- Settings sidebar order: Projects now appears after General.
- Electron upgraded to 41.2.0 for the desktop app.
### Fixed
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
- Daemon shutdown terminates all child processes cleanly using tree-kill.
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
- OpenCode now forwards provider retry errors instead of silently swallowing them.
- Codex import no longer reverts to the wrong default mode.
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
- Workspace navigation regression on web fixed.
- Duplicate workspace shell navigation eliminated.
- The 'Update installed' callout no longer flashes incorrectly.
- Browser pane reload focus and devtools handling.
- MCP terminal capture now includes scrollback.
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
## 0.1.69 - 2026-05-05
### Fixed
+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-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
npmDepsHash = "sha256-Fo95v2pBAW1i0K7WPoEwtKbwjeDZ5ed4vJ5p7I7LIYw=";
# 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).
+951 -784
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+5 -3
View File
@@ -15,6 +15,7 @@ export interface ArchiveTabAgent {
id: string;
title: string;
cwd: string;
createdAt: string;
}
interface ArchiveTabDaemonClient {
@@ -33,9 +34,9 @@ interface ArchiveTabDaemonClient {
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
predicate: (snapshot: { status: string; createdAt: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
): Promise<{ status: string; createdAt: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
@@ -132,6 +133,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
createdAt: snapshot.createdAt,
};
}
@@ -257,7 +259,7 @@ export async function reloadWorkspace(page: Page, workspaceId: string): Promise<
}
export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
const sessionsButton = page.getByTestId("sidebar-history");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
+2 -2
View File
@@ -31,9 +31,9 @@ export async function closeMobileAgentSidebar(page: Page): Promise<void> {
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
await expect(page.getByTestId("sidebar-history")).toBeInViewport({ timeout: 5_000 });
}
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
await expect(page.getByTestId("sidebar-history")).not.toBeInViewport({ timeout: 5_000 });
}
@@ -0,0 +1,348 @@
import { randomUUID } from "node:crypto";
import { execSync } from "node:child_process";
import path from "node:path";
import { buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
expectWorkspaceTabVisible,
type ArchiveTabAgent,
} from "./helpers/archive-tab";
import {
archiveLocalWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
type OpenedProject,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { expectSidebarWorkspaceSelected, waitForSidebarHydration } from "./helpers/workspace-ui";
interface SeededSidebarSessions {
firstWorkspace: OpenedProject;
secondWorkspace: OpenedProject;
firstProjectName: string;
secondProjectName: string;
unmappedProjectName: string;
agentsByNewest: [ArchiveTabAgent, ArchiveTabAgent, ArchiveTabAgent];
unmappedAgent: ArchiveTabAgent;
cleanupRepos: () => Promise<void>;
}
function requireServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function uniqueTitle(label: string): string {
return `sidebar-history-${label}-${randomUUID().slice(0, 8)}`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function checkoutBranch(repoPath: string, branch: string): void {
execSync(`git checkout ${JSON.stringify(branch)}`, {
cwd: repoPath,
stdio: "ignore",
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function seedSidebarSessionsJourney(clients: {
archiveClient: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
}): Promise<SeededSidebarSessions> {
const firstRepo = await createTempGitRepo("sidebar-history-a-", { branches: ["feature-a"] });
const secondRepo = await createTempGitRepo("sidebar-history-b-", { branches: ["feature-b"] });
const unmappedRepo = await createTempGitRepo("sidebar-history-unmapped-");
const workspaceIds: string[] = [];
const agentIds: string[] = [];
try {
checkoutBranch(firstRepo.path, "feature-a");
checkoutBranch(secondRepo.path, "feature-b");
const firstWorkspace = await openProjectViaDaemon(clients.workspaceClient, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(clients.workspaceClient, secondRepo.path);
workspaceIds.push(firstWorkspace.workspaceId, secondWorkspace.workspaceId);
const oldest = await createIdleAgent(clients.archiveClient, {
cwd: firstRepo.path,
title: uniqueTitle("oldest"),
});
await sleep(20);
const middle = await createIdleAgent(clients.archiveClient, {
cwd: firstRepo.path,
title: uniqueTitle("middle"),
});
await sleep(20);
const newest = await createIdleAgent(clients.archiveClient, {
cwd: secondRepo.path,
title: uniqueTitle("newest"),
});
const unmappedAgent = await createIdleAgent(clients.archiveClient, {
cwd: unmappedRepo.path,
title: uniqueTitle("unmapped"),
});
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, unmappedRepo.path);
agentIds.push(oldest.id, middle.id, newest.id, unmappedAgent.id);
return {
firstWorkspace,
secondWorkspace,
firstProjectName: path.basename(firstRepo.path),
secondProjectName: path.basename(secondRepo.path),
unmappedProjectName: path.basename(unmappedRepo.path),
agentsByNewest: [newest, middle, oldest],
unmappedAgent,
cleanupRepos: async () => {
await unmappedRepo.cleanup();
await secondRepo.cleanup();
await firstRepo.cleanup();
},
};
} catch (error) {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(clients.archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, workspaceId).catch(
() => undefined,
);
}
await secondRepo.cleanup();
await firstRepo.cleanup();
await unmappedRepo.cleanup();
throw error;
}
}
async function cleanupSeededSidebarSessions(
seeded: SeededSidebarSessions | null,
clients: {
archiveClient: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
},
): Promise<void> {
if (!seeded) {
return;
}
for (const agent of seeded.agentsByNewest) {
await archiveAgentFromDaemon(clients.archiveClient, agent.id).catch(() => undefined);
}
await archiveAgentFromDaemon(clients.archiveClient, seeded.unmappedAgent.id).catch(
() => undefined,
);
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, seeded.unmappedAgent.cwd).catch(
() => undefined,
);
await archiveLocalWorkspaceFromDaemon(
clients.workspaceClient,
seeded.secondWorkspace.workspaceId,
).catch(() => undefined);
await archiveLocalWorkspaceFromDaemon(
clients.workspaceClient,
seeded.firstWorkspace.workspaceId,
).catch(() => undefined);
await seeded.cleanupRepos();
}
async function openAppWithSeededWorkspaces(page: Page, seeded: SeededSidebarSessions) {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expectWorkspaceTreeVisible(page, seeded);
}
function expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded: SeededSidebarSessions) {
const [newest, middle, oldest] = seeded.agentsByNewest;
expect(new Date(newest.createdAt).getTime()).toBeGreaterThan(
new Date(middle.createdAt).getTime(),
);
expect(new Date(middle.createdAt).getTime()).toBeGreaterThan(
new Date(oldest.createdAt).getTime(),
);
}
async function expectWorkspaceTreeVisible(page: Page, seeded: SeededSidebarSessions) {
await expect(page.getByRole("button", { name: "Workspaces" })).toBeVisible();
await expect(page.getByText(seeded.firstProjectName, { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText(seeded.firstWorkspace.workspaceName, { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText(seeded.secondProjectName, { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText(seeded.secondWorkspace.workspaceName, { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Filter sessions" })).toHaveCount(0);
}
async function switchSidebarToSessions(page: Page) {
await page.getByRole("button", { name: "Sessions" }).click();
await expect(page.getByRole("button", { name: "Filter sessions" })).toBeVisible({
timeout: 30_000,
});
}
async function switchSidebarToWorkspaces(page: Page, seeded: SeededSidebarSessions) {
await page.getByRole("button", { name: "Workspaces" }).click();
await expectWorkspaceTreeVisible(page, seeded);
for (const agent of seeded.agentsByNewest) {
await expect(sessionRow(page, agent)).toHaveCount(0);
}
}
function sessionRow(page: Page, agent: ArchiveTabAgent) {
return page.getByRole("button", { name: new RegExp(escapeRegex(agent.title)) });
}
async function expectSidebarSessionRows(page: Page, agents: readonly ArchiveTabAgent[]) {
for (const agent of agents) {
await expect(sessionRow(page, agent)).toBeVisible({ timeout: 30_000 });
}
}
async function expectOnlySidebarSessionRows(
page: Page,
input: {
visible: readonly ArchiveTabAgent[];
hidden: readonly ArchiveTabAgent[];
},
) {
await expectSidebarSessionRows(page, input.visible);
for (const agent of input.hidden) {
await expect(sessionRow(page, agent)).toHaveCount(0);
}
}
async function expectUnmappedSessionHidden(page: Page, seeded: SeededSidebarSessions) {
await expect(sessionRow(page, seeded.unmappedAgent)).toHaveCount(0);
await page.getByRole("button", { name: "Filter sessions" }).click();
await expect(
page.getByRole("button", {
name: new RegExp(`^${escapeRegex(seeded.unmappedProjectName)}$`),
}),
).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function expectNewestSessionRowFirst(page: Page, newest: ArchiveTabAgent) {
await expect(sessionRow(page, newest)).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () => {
const titles = await page.getByRole("button").allTextContents();
return titles.find((title) => title.includes("sidebar-history-")) ?? "";
})
.toContain(newest.title);
}
async function chooseSessionsFilter(page: Page, name: string | RegExp) {
await page.getByRole("button", { name: "Filter sessions" }).click();
await page.getByRole("button", { name }).click();
}
async function chooseProjectSessionsFilter(page: Page, projectName: string) {
await chooseSessionsFilter(page, new RegExp(`^${escapeRegex(projectName)}$`));
}
async function openFirstWorkspace(page: Page, seeded: SeededSidebarSessions) {
const serverId = requireServerId();
await page.goto(buildHostWorkspaceRoute(serverId, seeded.firstWorkspace.workspaceId));
await waitForWorkspaceTabsVisible(page);
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: seeded.firstWorkspace.workspaceId,
});
}
async function navigateFromSessionRow(page: Page, agent: ArchiveTabAgent) {
await sessionRow(page, agent).click();
}
async function expectWorkspaceFocusedOnSession(
page: Page,
input: { workspaceId: string; agent: ArchiveTabAgent },
) {
const serverId = requireServerId();
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, input.workspaceId), {
timeout: 30_000,
});
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, input.agent.id);
await expect(
page.getByTestId(`workspace-tab-agent_${input.agent.id}`).filter({ visible: true }).first(),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
}
async function openHistoryFromSidebar(page: Page) {
await page.getByRole("button", { name: "History", exact: true }).click();
await expect(page).toHaveURL(buildHostSessionsRoute(requireServerId()), {
timeout: 30_000,
});
}
async function runSidebarHistorySessionsJourney(page: Page) {
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceClient = await connectNewWorkspaceDaemonClient();
let seeded: SeededSidebarSessions | null = null;
try {
seeded = await seedSidebarSessionsJourney({ archiveClient, workspaceClient });
expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded);
const [newest, middle, oldest] = seeded.agentsByNewest;
await openAppWithSeededWorkspaces(page, seeded);
await switchSidebarToSessions(page);
await expectSidebarSessionRows(page, seeded.agentsByNewest);
await expectUnmappedSessionHidden(page, seeded);
await expectNewestSessionRowFirst(page, newest);
await switchSidebarToWorkspaces(page, seeded);
await switchSidebarToSessions(page);
await chooseProjectSessionsFilter(page, seeded.secondProjectName);
await expectOnlySidebarSessionRows(page, {
visible: [newest],
hidden: [middle, oldest],
});
await chooseSessionsFilter(page, "All");
await expectSidebarSessionRows(page, seeded.agentsByNewest);
await openFirstWorkspace(page, seeded);
await switchSidebarToSessions(page);
await navigateFromSessionRow(page, newest);
await expectWorkspaceFocusedOnSession(page, {
workspaceId: seeded.secondWorkspace.workspaceId,
agent: newest,
});
await openHistoryFromSidebar(page);
} finally {
await cleanupSeededSidebarSessions(seeded, { archiveClient, workspaceClient });
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
}
}
test.describe("Sidebar History Sessions journey", () => {
test.describe.configure({ timeout: 300_000 });
test("toggles sessions, filters by project, and opens a session tab", async ({ page }) => {
await runSidebarHistorySessionsJourney(page);
});
});
@@ -1,10 +1,10 @@
appId: sh.paseo
---
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
# Ensure sidebar is closed: if sidebar-history is visible, close it via swipe left
- runFlow:
when:
visible:
id: "sidebar-sessions"
id: "sidebar-history"
commands:
- swipe:
direction: LEFT
@@ -21,7 +21,7 @@ appId: sh.paseo
# Verify sidebar opened
- assertVisible:
id: "sidebar-sessions"
id: "sidebar-history"
- takeScreenshot: 01-sidebar-opened
# Close sidebar via swipe left
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
+265
View File
@@ -0,0 +1,265 @@
/**
* @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 { state, startHostRuntimeBootstrapMock } = vi.hoisted(() => {
return {
state: {
anyOnlineHostServerId: null as string | null,
daemonStartError: null as string | null,
daemonStartIsRunning: false,
latestBootstrapState: null as HostRuntimeBootstrapState | null,
},
startHostRuntimeBootstrapMock: vi.fn(),
};
});
vi.mock("@/styles/unistyles", () => ({}));
vi.mock("@gorhom/portal", () => ({
PortalProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@tanstack/react-query", () => ({
QueryClientProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("expo-linking", () => ({ addEventListener: vi.fn(), getInitialURL: vi.fn() }));
vi.mock("expo-notifications", () => ({
addNotificationResponseReceivedListener: vi.fn(),
getLastNotificationResponseAsync: vi.fn(),
setNotificationHandler: vi.fn(),
}));
vi.mock("expo-router", () => ({
Stack: Object.assign(() => null, { Protected: () => null, Screen: () => null }),
useGlobalSearchParams: () => ({}),
usePathname: () => "/",
useRouter: () => ({ navigate: vi.fn(), replace: vi.fn() }),
}));
vi.mock("react-native", () => ({
View: ({ children }: { children?: React.ReactNode }) => children,
}));
vi.mock("react-native-gesture-handler", () => ({
Gesture: { Pan: () => ({}) },
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("react-native-keyboard-controller", () => ({
KeyboardProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("react-native-reanimated", () => ({
Extrapolation: { CLAMP: "clamp" },
interpolate: vi.fn(),
runOnJS: (fn: () => void) => fn,
useSharedValue: (value: unknown) => ({ value }),
}));
vi.mock("react-native-safe-area-context", () => ({
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? factory({ colors: { surface0: "#000", foreground: "#fff" } })
: factory,
},
UnistylesRuntime: { setAdaptiveThemes: vi.fn(), setTheme: vi.fn() },
useUnistyles: () => ({ theme: { colors: { surface0: "#000", foreground: "#fff" } } }),
}));
vi.mock("@/components/command-center", () => ({ CommandCenter: () => null }));
vi.mock("@/components/worktree-setup-callout-source", () => ({
WorktreeSetupCalloutSource: () => null,
}));
vi.mock("@/components/download-toast", () => ({ DownloadToast: () => null }));
vi.mock("@/components/quitting-overlay", () => ({ QuittingOverlay: () => null }));
vi.mock("@/components/keyboard-shortcuts-dialog", () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock("@/components/left-sidebar", () => ({ LeftSidebar: () => null }));
vi.mock("@/components/project-picker-modal", () => ({ ProjectPickerModal: () => null }));
vi.mock("@/components/workspace-setup-dialog", () => ({ WorkspaceSetupDialog: () => null }));
vi.mock("@/components/workspace-shortcut-targets-subscriber", () => ({
WorkspaceShortcutTargetsSubscriber: () => null,
}));
vi.mock("@/constants/layout", () => ({
getIsElectronRuntime: () => false,
useIsCompactFormFactor: () => false,
}));
vi.mock("@/constants/platform", () => ({ isNative: false, isWeb: true }));
vi.mock("@/contexts/horizontal-scroll-context", () => ({
HorizontalScrollProvider: ({ children }: { children: React.ReactNode }) => children,
useHorizontalScrollOptional: () => null,
}));
vi.mock("@/contexts/session-context", () => ({
SessionProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/contexts/sidebar-animation-context", () => ({
SidebarAnimationProvider: ({ children }: { children: React.ReactNode }) => children,
useSidebarAnimation: () => ({
animateToClose: vi.fn(),
animateToOpen: vi.fn(),
backdropOpacity: { value: 0 },
gestureAnimatingRef: { current: false },
isGesturing: { value: false },
openGestureRef: {},
translateX: { value: 0 },
windowWidth: 320,
}),
}));
vi.mock("@/contexts/sidebar-callout-context", () => ({
SidebarCalloutProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/contexts/toast-context", () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/contexts/voice-context", () => ({
VoiceProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/app/host-runtime-bootstrap", () => ({
startDaemonIfGateAllows: vi.fn(),
startHostRuntimeBootstrap: startHostRuntimeBootstrapMock,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({ shouldUseDesktopDaemon: () => false }));
vi.mock("@/desktop/electron/events", () => ({ listenToDesktopEvent: vi.fn() }));
vi.mock("@/desktop/electron/window", () => ({ updateDesktopWindowControls: vi.fn() }));
vi.mock("@/desktop/host", () => ({ getDesktopHost: () => null }));
vi.mock("@/desktop/settings/desktop-settings", () => ({ loadDesktopSettings: vi.fn() }));
vi.mock("@/desktop/updates/rosetta-callout-source", () => ({ RosettaCalloutSource: () => null }));
vi.mock("@/desktop/updates/update-callout-source", () => ({ UpdateCalloutSource: () => null }));
vi.mock("@/hooks/use-active-worktree-new-action", () => ({ useActiveWorktreeNewAction: vi.fn() }));
vi.mock("@/hooks/use-favicon-status", () => ({ useFaviconStatus: vi.fn() }));
vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: vi.fn() }));
vi.mock("@/hooks/use-latched-boolean", () => ({ useLatchedBoolean: (value: boolean) => value }));
vi.mock("@/hooks/use-open-project", () => ({ useOpenProject: () => vi.fn() }));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({
isLoading: false,
settings: { theme: "dark" },
updateSettings: vi.fn(),
}),
}));
vi.mock("@/hooks/use-stable-event", () => ({ useStableEvent: (fn: unknown) => fn }));
vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace: vi.fn() }));
vi.mock("@/keyboard/keyboard-action-dispatcher", () => ({
keyboardActionDispatcher: { dispatch: vi.fn() },
}));
vi.mock("@/polyfills/crypto", () => ({ polyfillCrypto: vi.fn() }));
vi.mock("@/query/query-client", () => ({ queryClient: {} }));
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => ({
boot: vi.fn(),
getEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
subscribeAll: () => vi.fn(),
subscribeHostList: () => vi.fn(),
}),
useHostMutations: () => ({ upsertConnectionFromOfferUrl: vi.fn() }),
useHostRuntimeClient: () => null,
useHosts: () => [],
}));
vi.mock("@/runtime/daemon-start-service", () => ({
getDaemonStartService: () => ({
getLastError: () => state.daemonStartError,
isRunning: () => state.daemonStartIsRunning,
recordError: vi.fn(),
start: vi.fn(),
subscribe: () => vi.fn(),
}),
}));
vi.mock("@/stores/panel-store", () => ({ usePanelStore: vi.fn(() => vi.fn()) }));
vi.mock("@/stores/session-store", () => ({
useSessionStore: { getState: () => ({ sessions: {} }) },
}));
vi.mock("@/styles/theme", () => ({ THEME_TO_UNISTYLES: { dark: "dark" } }));
vi.mock("@/utils/active-host", () => ({ resolveActiveHost: () => null }));
vi.mock("@/utils/desktop-sidebar-toggle", () => ({
toggleDesktopSidebarsWithCheckoutIntent: vi.fn(),
}));
vi.mock("@/utils/host-routes", () => ({
buildHostRootRoute: (serverId: string) => `/h/${serverId}`,
mapPathnameToServer: () => null,
parseHostAgentRouteFromPathname: () => null,
parseServerIdFromPathname: () => null,
parseWorkspaceOpenIntent: () => null,
}));
vi.mock("@/utils/notification-routing", () => ({
buildNotificationRoute: () => "/",
resolveNotificationTarget: () => ({}),
}));
vi.mock("@/utils/os-notifications", () => ({
WEB_NOTIFICATION_CLICK_EVENT: "notification-click",
ensureOsNotificationPermission: vi.fn(),
}));
vi.mock("@/utils/workspace-execution", () => ({
resolveWorkspaceIdByExecutionDirectory: () => null,
}));
vi.mock("@/utils/workspace-navigation", () => ({ prepareWorkspaceTab: vi.fn() }));
let useHostRuntimeBootstrapState: typeof import("./_layout").useHostRuntimeBootstrapState;
function BootstrapStateProbe() {
state.latestBootstrapState = useHostRuntimeBootstrapState();
return null;
}
describe("HostRuntimeBootstrapProvider startup give-up timer", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
state.anyOnlineHostServerId = null;
state.daemonStartError = null;
state.daemonStartIsRunning = false;
state.latestBootstrapState = null;
startHostRuntimeBootstrapMock.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.useRealTimers();
});
async function renderProvider() {
const layout = await import("./_layout");
useHostRuntimeBootstrapState = layout.useHostRuntimeBootstrapState;
await act(async () => {
root.render(
<layout.HostRuntimeBootstrapProvider>
<BootstrapStateProbe />
</layout.HostRuntimeBootstrapProvider>,
);
});
}
it("gives up after 5s when the env override host is not online", async () => {
await renderProvider();
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
await act(async () => {
vi.advanceTimersByTime(5_000);
});
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(true);
});
it("does not give up after 5s when an env override host is online", async () => {
state.anyOnlineHostServerId = "server-local";
await renderProvider();
await act(async () => {
vi.advanceTimersByTime(5_000);
});
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
});
});
+3 -13
View File
@@ -5,6 +5,7 @@ import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
default as React,
createContext,
type ReactNode,
useCallback,
@@ -65,7 +66,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostMutations,
useHostRuntimeClient,
useHosts,
@@ -312,7 +312,7 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
return settings.daemon.manageBuiltInDaemon;
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const store = getHostRuntimeStore();
const daemonStartService = getDaemonStartService({ store });
@@ -327,16 +327,12 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const waitForConfiguredLocalDaemon =
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
useEffect(() => {
if (
anyOnlineHostServerId ||
daemonStartError ||
daemonStartIsRunning ||
waitForConfiguredLocalDaemon ||
hasGivenUpWaitingForHost
) {
return;
@@ -347,13 +343,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
clearTimeout(handle);
};
}, [
anyOnlineHostServerId,
daemonStartError,
daemonStartIsRunning,
waitForConfiguredLocalDaemon,
hasGivenUpWaitingForHost,
]);
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
+8 -1
View File
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function HostNewWorkspaceRoute() {
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
const params = useLocalSearchParams<{
serverId?: string;
dir?: string;
name?: string;
title?: string;
}>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
const displayName = typeof params.name === "string" ? params.name : undefined;
const headerTitle = typeof params.title === "string" ? params.title : undefined;
if (!sourceDirectory) {
return null;
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
headerTitle={headerTitle}
/>
);
}
@@ -67,6 +67,22 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
});
it("matches across label, provider, and description with multi-token fuzzy search", () => {
const row = {
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
provider: "opencode",
providerLabel: "OpenCode",
modelId: "opencode-zen/kimi-k2.5",
modelLabel: "Kimi K2.5",
description: "OpenCode Zen - kimi",
};
expect(matchesSearch(row, "kimi zen")).toBe(true);
expect(matchesSearch(row, "zen kimi")).toBe(true);
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
expect(matchesSearch(row, "kimi gemini")).toBe(false);
});
it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
@@ -48,7 +48,10 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
return true;
}
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
.join(" ")
.toLowerCase();
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
return tokens.every((token) => haystack.includes(token));
}
+139 -38
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
import { FolderPlus, Settings } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -32,8 +32,9 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarHistoryNavigationRow } from "@/components/sidebar/sidebar-history-navigation-row";
import { SidebarShellHeader } from "@/components/sidebar/sidebar-shell-header";
import { SidebarWorkspacesView } from "@/components/sidebar/sidebar-workspaces-view";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -62,9 +63,14 @@ import {
buildSettingsRoute,
mapPathnameToServer,
} from "@/utils/host-routes";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import {
SidebarSessionsToggle,
SidebarSessionsView,
type SidebarSessionFilter,
type SidebarSessionViewMode,
useSidebarSessionsController,
} from "@/sidebar-sessions";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const MIN_CHAT_WIDTH = 400;
@@ -80,6 +86,14 @@ interface SidebarSharedProps {
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
sidebarViewMode: SidebarSessionViewMode;
setSidebarViewMode: (mode: SidebarSessionViewMode) => void;
sidebarSessionFilter: SidebarSessionFilter;
setSidebarSessionFilter: (filter: SidebarSessionFilter) => void;
groupByProject: boolean;
previewExpandedProjects: ReadonlySet<string>;
setGroupByProject: (next: boolean) => void;
toggleProjectPreviewExpanded: (projectKey: string) => void;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
@@ -182,6 +196,16 @@ export const LeftSidebar = memo(function LeftSidebar({
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const {
sidebarViewMode,
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
previewExpandedProjects,
setGroupByProject,
toggleProjectPreviewExpanded,
} = useSidebarSessionsController({ serverId: activeServerId });
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
@@ -247,6 +271,14 @@ export const LeftSidebar = memo(function LeftSidebar({
activeServerId,
activeHostLabel,
activeHostStatusColor,
sidebarViewMode,
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
previewExpandedProjects,
setGroupByProject,
toggleProjectPreviewExpanded,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
@@ -485,6 +517,14 @@ function MobileSidebar({
activeServerId,
activeHostLabel,
activeHostStatusColor,
sidebarViewMode,
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
previewExpandedProjects,
setGroupByProject,
toggleProjectPreviewExpanded,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
@@ -507,8 +547,6 @@ function MobileSidebar({
closeToAgent,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const {
translateX,
backdropOpacity,
@@ -664,6 +702,30 @@ function MobileSidebar({
],
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
);
const toggleSlot = useMemo(
() => (
<SidebarSessionsToggle
serverId={activeServerId}
mode={sidebarViewMode}
filter={sidebarSessionFilter}
projects={projects}
groupByProject={groupByProject}
onModeChange={setSidebarViewMode}
onFilterChange={setSidebarSessionFilter}
onGroupByProjectChange={setGroupByProject}
/>
),
[
activeServerId,
groupByProject,
projects,
sidebarSessionFilter,
sidebarViewMode,
setGroupByProject,
setSidebarSessionFilter,
setSidebarViewMode,
],
);
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -672,29 +734,35 @@ function MobileSidebar({
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
<SidebarShellHeader toggleSlot={toggleSlot}>
<SidebarHistoryNavigationRow onPress={handleViewMore} />
</SidebarShellHeader>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
{sidebarViewMode === "workspaces" ? (
<SidebarWorkspacesView
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isInitialLoad={isInitialLoad}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
) : (
<SidebarSessionsView
serverId={activeServerId}
projects={projects}
filter={sidebarSessionFilter}
groupByProject={groupByProject}
previewExpandedProjects={previewExpandedProjects}
collapsedProjectKeys={collapsedProjectKeys}
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
onProjectCollapsedToggle={toggleProjectCollapsed}
/>
)}
<SidebarFooter
@@ -723,6 +791,14 @@ function DesktopSidebar({
activeServerId,
activeHostLabel,
activeHostStatusColor,
sidebarViewMode,
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter,
groupByProject,
previewExpandedProjects,
setGroupByProject,
toggleProjectPreviewExpanded,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
@@ -743,8 +819,6 @@ function DesktopSidebar({
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
@@ -799,6 +873,30 @@ function DesktopSidebar({
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[],
);
const toggleSlot = useMemo(
() => (
<SidebarSessionsToggle
serverId={activeServerId}
mode={sidebarViewMode}
filter={sidebarSessionFilter}
projects={projects}
groupByProject={groupByProject}
onModeChange={setSidebarViewMode}
onFilterChange={setSidebarSessionFilter}
onGroupByProjectChange={setGroupByProject}
/>
),
[
activeServerId,
groupByProject,
projects,
sidebarSessionFilter,
sidebarViewMode,
setGroupByProject,
setSidebarSessionFilter,
setSidebarViewMode,
],
);
if (!isOpen) {
return null;
@@ -807,31 +905,37 @@ function DesktopSidebar({
return (
<Animated.View style={desktopSidebarStyle}>
<View style={desktopSidebarBorderStyle}>
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
<SidebarShellHeader
topSpacerStyle={padding.top > 0 ? paddingTopSpacerStyle : undefined}
toggleSlot={toggleSlot}
withTitlebarDragRegion
>
<SidebarHistoryNavigationRow onPress={handleViewMore} />
</SidebarShellHeader>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
{sidebarViewMode === "workspaces" ? (
<SidebarWorkspacesView
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isInitialLoad={isInitialLoad}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
/>
) : (
<SidebarSessionsView
serverId={activeServerId}
projects={projects}
filter={sidebarSessionFilter}
groupByProject={groupByProject}
previewExpandedProjects={previewExpandedProjects}
collapsedProjectKeys={collapsedProjectKeys}
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
onProjectCollapsedToggle={toggleProjectCollapsed}
/>
)}
<SidebarCalloutSlot />
@@ -898,9 +1002,6 @@ const styles = StyleSheet.create((theme) => ({
width: 10,
zIndex: 10,
},
sidebarDragArea: {
position: "relative",
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -9,6 +9,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { Folder } from "lucide-react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -19,6 +20,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
interface PathRowProps {
path: string;
@@ -63,6 +65,7 @@ export function ProjectPickerModal() {
const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const intent = useKeyboardShortcutsStore((s) => s.projectPickerIntent);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const client = useHostRuntimeClient(serverId ?? "");
@@ -115,6 +118,16 @@ export function ProjectPickerModal() {
setIsSubmitting(true);
try {
if (intent.kind === "new-workspace") {
router.navigate(
buildHostNewWorkspaceRoute(serverId, trimmed, {
headerTitle: intent.headerTitle,
}) as never,
);
setOpen(false);
return;
}
const didOpenProject = await openProject(trimmed);
if (didOpenProject) {
setOpen(false);
@@ -123,7 +136,7 @@ export function ProjectPickerModal() {
setIsSubmitting(false);
}
},
[client, openProject, serverId, setOpen],
[client, intent, openProject, serverId, setOpen],
);
const handleSubmitCustom = useCallback(() => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { Text } from "react-native";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SidebarProjectSection } from "./sidebar-collapsible-project-section";
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) =>
typeof styles === "function"
? styles({
borderRadius: { lg: 8 },
colors: {
border: "#dddddd",
surface2: "#eeeeee",
surfaceSidebarHover: "#f5f5f5",
},
iconSize: { md: 16 },
shadow: { md: {} },
spacing: { 1: 4, 2: 8, 3: 12 },
})
: styles,
},
}));
vi.mock("@/components/sidebar/sidebar-project-row-visual", () => ({
SidebarProjectIcon: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
SidebarProjectRowVisual: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
}));
afterEach(cleanup);
const collapsedFooter = <Text>Footer row</Text>;
describe("SidebarProjectSection", () => {
it("renders the header and expanded children", () => {
const { getByTestId, getByText } = render(
<SidebarProjectSection
projectKey="project-a"
projectName="Project A"
iconDataUri={null}
onPress={vi.fn()}
isHovered={false}
chevron="collapse"
isCollapsed={false}
testID="project-a-header"
>
<Text>Child row</Text>
</SidebarProjectSection>,
);
expect(getByTestId("project-a-header")).toBeTruthy();
expect(getByText("Project A")).toBeTruthy();
expect(getByText("Child row")).toBeTruthy();
});
it("keeps the header visible and hides children plus footer when collapsed", () => {
const { getByTestId, queryByText } = render(
<SidebarProjectSection
projectKey="project-a"
projectName="Project A"
iconDataUri={null}
onPress={vi.fn()}
isHovered={false}
chevron="expand"
isCollapsed
footer={collapsedFooter}
testID="project-a-header"
>
<Text>Child row</Text>
</SidebarProjectSection>,
);
expect(getByTestId("project-a-header")).toBeTruthy();
expect(queryByText("Child row")).toBeNull();
expect(queryByText("Footer row")).toBeNull();
});
});
@@ -0,0 +1,277 @@
import React, {
forwardRef,
memo,
useCallback,
useMemo,
type ComponentType,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
import {
Pressable,
View,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type ViewProps,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import {
SidebarProjectIcon,
SidebarProjectRowVisual,
} from "@/components/sidebar/sidebar-project-row-visual";
export type SidebarProjectChevronDirection = "expand" | "collapse";
export interface SidebarProjectHeaderRowProps {
projectName: string;
iconDataUri: string | null;
onPress: () => void;
isHovered: boolean;
/** Visual collapsed state. When set, drives the chevron direction (down=collapse / right=expand). */
chevron?: SidebarProjectChevronDirection | null;
selected?: boolean;
isDragging?: boolean;
/** Replaces the chevron+icon leading visual entirely (e.g. archiving spinner, workspace status icon). */
leadingVisualOverride?: ReactElement | null;
/** Appended after the project name. Kebab, new-worktree button, shortcut badge. */
trailingSlot?: ReactNode;
/** Long-press / drag handlers. */
onPressIn?: (event: GestureResponderEvent) => void;
onTouchMove?: (event: GestureResponderEvent) => void;
onPressOut?: (event: GestureResponderEvent) => void;
testID?: string;
accessibilityLabel?: string;
/** Substitute for the inner Pressable (e.g. ContextMenuTrigger). Receives the same props. */
PressableComponent?: ComponentType<PressableProps & { ref?: Ref<View> }>;
}
export interface SidebarProjectSectionProps {
projectKey: string;
projectName: string;
iconDataUri: string | null;
onPress: () => void;
isHovered: boolean;
chevron: "expand" | "collapse" | null;
selected?: boolean;
isDragging?: boolean;
trailingSlot?: ReactNode;
testID?: string;
children?: ReactNode;
footer?: ReactNode;
isCollapsed: boolean;
headerProps?: Pick<
SidebarProjectHeaderRowProps,
| "accessibilityLabel"
| "leadingVisualOverride"
| "onPressIn"
| "onTouchMove"
| "onPressOut"
| "PressableComponent"
>;
}
export const SidebarProjectSection = memo(function SidebarProjectSection({
projectName,
iconDataUri,
onPress,
isHovered,
chevron,
selected = false,
isDragging = false,
trailingSlot = null,
testID,
children = null,
footer = null,
isCollapsed,
headerProps,
}: SidebarProjectSectionProps): ReactElement {
return (
<>
<SidebarProjectHeaderRow
projectName={projectName}
iconDataUri={iconDataUri}
chevron={chevron}
isHovered={isHovered}
isDragging={isDragging}
selected={selected}
trailingSlot={trailingSlot}
onPress={onPress}
testID={testID}
{...headerProps}
/>
{!isCollapsed ? (
<>
{children}
{footer}
</>
) : null}
</>
);
});
/**
* Shared chrome for a project row that names a section: pressable, hover/press/selected
* styles, on-hover chevron leading visual, and a trailing actions slot.
*
* Hover is controlled — callers wrap with their own outer View carrying onPointerEnter/Leave
* (workspace tree adds drag-handle attrs to that wrapper; sessions wraps a plain View).
*/
export const SidebarProjectHeaderRow = memo(
forwardRef<View, SidebarProjectHeaderRowProps>(function SidebarProjectHeaderRow(
{
projectName,
iconDataUri,
onPress,
isHovered,
chevron = null,
selected = false,
isDragging = false,
leadingVisualOverride = null,
trailingSlot = null,
onPressIn,
onTouchMove,
onPressOut,
testID,
accessibilityLabel,
PressableComponent = Pressable,
},
ref,
): ReactElement {
const showChevron = isHovered && chevron !== null;
const leadingVisual = useMemo<ReactElement | null>(() => {
if (showChevron && chevron) {
return (
<View style={sidebarProjectHeaderRowStyles.leadingSlot}>
<SidebarProjectChevron chevron={chevron} />
</View>
);
}
if (leadingVisualOverride) {
return leadingVisualOverride;
}
return <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />;
}, [chevron, iconDataUri, leadingVisualOverride, projectName, showChevron]);
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
sidebarProjectHeaderRowStyles.row,
isDragging && sidebarProjectHeaderRowStyles.dragging,
selected && sidebarProjectHeaderRowStyles.selected,
isHovered && sidebarProjectHeaderRowStyles.hovered,
pressed && sidebarProjectHeaderRowStyles.pressed,
],
[isDragging, isHovered, selected],
);
return (
<PressableComponent
ref={ref}
style={pressableStyle}
onPress={onPress}
onPressIn={onPressIn}
onTouchMove={onTouchMove}
onPressOut={onPressOut}
accessibilityLabel={accessibilityLabel}
testID={testID}
>
<SidebarProjectRowVisual
projectName={projectName}
iconDataUri={iconDataUri}
leadingVisual={leadingVisual}
/>
{trailingSlot}
</PressableComponent>
);
}),
);
function SidebarProjectChevron({
chevron,
}: {
chevron: SidebarProjectChevronDirection;
}): ReactElement {
if (chevron === "collapse") {
return <ChevronDown size={14} color="#9ca3af" />;
}
return <ChevronRight size={14} color="#9ca3af" />;
}
/**
* Outer wrapper that carries hover state for a `<SidebarProjectHeaderRow>` plus extra props
* (drag handle attrs, ref). Sessions uses the lighter `<SidebarProjectHeaderHoverWrap>`.
*/
export const SidebarProjectHeaderHoverWrap = forwardRef<
View,
ViewProps & {
onHoverChange: (hovered: boolean) => void;
}
>(function SidebarProjectHeaderHoverWrap({ onHoverChange, children, ...rest }, ref): ReactElement {
const handleEnter = useCallback(() => onHoverChange(true), [onHoverChange]);
const handleLeave = useCallback(() => onHoverChange(false), [onHoverChange]);
return (
<View ref={ref} {...rest} onPointerEnter={handleEnter} onPointerLeave={handleLeave}>
{children}
</View>
);
});
export const sidebarProjectHeaderRowStyles = StyleSheet.create((theme) => ({
row: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
userSelect: "none",
},
hovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
pressed: {
backgroundColor: theme.colors.surface2,
},
selected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
dragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
leadingSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
}));
/**
* Indent style for rows nested under a `<SidebarProjectHeaderRow>`. Workspace rows and
* grouped-session rows both apply this so children visually align across surfaces.
*/
export const sidebarProjectChildIndentStyles = StyleSheet.create((theme) => ({
childRow: {
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
},
}));
@@ -108,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
buttonHovered: {
@@ -0,0 +1,22 @@
import { usePathname } from "expo-router";
import { History } from "lucide-react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
interface SidebarHistoryNavigationRowProps {
onPress: () => void;
}
export function SidebarHistoryNavigationRow({ onPress }: SidebarHistoryNavigationRowProps) {
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
return (
<SidebarHeaderRow
icon={History}
label="History"
onPress={onPress}
isActive={isSessionsActive}
testID="sidebar-history"
/>
);
}
@@ -0,0 +1,111 @@
import React, { memo, useMemo, type ReactElement } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
export interface SidebarProjectRowVisualProps {
projectName: string;
iconDataUri: string | null;
leadingVisual?: ReactElement | null;
}
export const SidebarProjectRowVisual = memo(function SidebarProjectRowVisual({
projectName,
iconDataUri,
leadingVisual = null,
}: SidebarProjectRowVisualProps): ReactElement {
return (
<View style={styles.projectRowLeft}>
{leadingVisual ?? <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />}
<SidebarProjectName projectName={projectName} />
</View>
);
});
export const SidebarProjectIcon = memo(function SidebarProjectIcon({
projectName,
iconDataUri,
}: {
projectName: string;
iconDataUri: string | null;
}): ReactElement {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
return (
<View style={styles.projectLeadingVisualSlot}>
{iconDataUri ? (
<Image source={imageSource} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
</View>
);
});
const SidebarProjectName = memo(function SidebarProjectName({
projectName,
}: {
projectName: string;
}): ReactElement {
return (
<View style={styles.projectTitleGroup}>
<Text style={styles.projectTitle} numberOfLines={1}>
{projectName}
</Text>
</View>
);
});
const styles = StyleSheet.create((theme) => ({
projectRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
projectLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
projectIcon: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: 9,
},
projectTitleGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
projectTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
},
}));
@@ -0,0 +1,187 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Pressable, View } from "react-native";
import {
SidebarProjectTrailingActions,
type SidebarProjectCreateButtonConfig,
} from "./sidebar-project-trailing-actions";
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
const { navigateMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
}));
vi.mock("expo-router", () => ({
router: {
navigate: navigateMock,
},
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
FolderPlus: createIcon("FolderPlus"),
MoreVertical: createIcon("MoreVertical"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
Trash2: createIcon("Trash2"),
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) =>
typeof styles === "function"
? styles({
borderRadius: { md: 6 },
colors: {
foreground: "#111111",
foregroundMuted: "#666666",
surface2: "#eeeeee",
surfaceSidebarHover: "#f5f5f5",
},
fontSize: { sm: 14 },
spacing: { 2: 8 },
})
: styles,
},
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
function ThemedComponent(props: Record<string, unknown>) {
return <Component {...props} />;
},
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<View testID={testID}>{children}</View>
),
DropdownMenuTrigger: ({
children,
testID,
}: {
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
testID?: string;
}) => (
<Pressable testID={testID}>
{typeof children === "function" ? children({ hovered: false }) : children}
</Pressable>
),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => <View />,
}));
vi.mock("@/constants/platform", () => ({
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
afterEach(() => {
cleanup();
navigateMock.mockClear();
});
const newWorkspaceCreateButton: SidebarProjectCreateButtonConfig = {
onPress: () => {},
accessibilityLabel: "New workspace",
testID: "sidebar-project-new-worktree-project-a",
tooltipLabel: "New workspace",
icon: "folder-plus",
};
describe("SidebarProjectTrailingActions", () => {
it("renders the kebab and create button when configured", () => {
const { getByTestId } = render(
<SidebarProjectTrailingActions
projectKey="project-a"
projectName="Project A"
serverId="server-1"
isHovered
createButton={newWorkspaceCreateButton}
onRemoveProject={vi.fn()}
/>,
);
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
expect(getByTestId("sidebar-project-new-worktree-project-a")).toBeTruthy();
});
it("renders only the kebab when createButton is null", () => {
const { getByTestId, queryByTestId } = render(
<SidebarProjectTrailingActions
projectKey="project-a"
projectName="Project A"
serverId="server-1"
isHovered
createButton={null}
onRemoveProject={vi.fn()}
/>,
);
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
});
it("uses the create button override instead of the default route", () => {
const createOnPress = vi.fn();
function Harness() {
const createButton = React.useMemo(
() => ({
onPress: createOnPress,
accessibilityLabel: "New session",
testID: "sidebar-project-project-a-new-session-button",
tooltipLabel: "New session",
icon: "plus" as const,
}),
[],
);
return (
<SidebarProjectTrailingActions
projectKey="project-a"
projectName="Project A"
serverId="server-1"
isHovered
createButton={createButton}
onRemoveProject={vi.fn()}
/>
);
}
const { getByTestId } = render(<Harness />);
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
expect(createOnPress).toHaveBeenCalledOnce();
expect(navigateMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,441 @@
import React, { useCallback, useState, type ReactElement } from "react";
import {
ActivityIndicator,
Pressable,
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { router } from "expo-router";
import { FolderPlus, MoreVertical, Plus, Settings, Trash2 } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
export interface SidebarProjectCreateButtonConfig {
onPress: () => void;
accessibilityLabel: string;
testID: string;
tooltipLabel: string;
icon: "folder-plus" | "plus";
showShortcutHint?: boolean;
}
export interface SidebarProjectTrailingActionsProps {
projectKey: string;
serverId: string | null;
isHovered: boolean;
projectName?: string;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending" | "success";
workspaces?: readonly SidebarWorkspaceEntry[];
createButton: SidebarProjectCreateButtonConfig | null;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedPlus = withUnistyles(Plus);
const ThemedSettings = withUnistyles(Settings);
const ThemedTrash2 = withUnistyles(Trash2);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
export function SidebarProjectTrailingActions({
projectKey,
serverId,
isHovered,
projectName,
onRemoveProject,
removeProjectStatus,
workspaces = [],
createButton,
}: SidebarProjectTrailingActionsProps): ReactElement {
const isMobileBreakpoint = useIsCompactFormFactor();
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const fallbackRemoval = useSidebarProjectRemoval({
projectKey,
projectName,
serverId,
workspaces,
});
const resolvedRemoveProject = onRemoveProject ?? fallbackRemoval.onRemoveProject;
const resolvedRemoveProjectStatus = removeProjectStatus ?? fallbackRemoval.removeProjectStatus;
return (
<View style={styles.projectTrailingActions}>
{createButton ? (
<ProjectCreateButton actionsVisible={actionsVisible} createButton={createButton} />
) : null}
<View
style={!actionsVisible && styles.projectKebabButtonHidden}
pointerEvents={actionsVisible ? "auto" : "none"}
>
<ProjectKebabMenu
projectKey={projectKey}
onRemoveProject={resolvedRemoveProject}
removeProjectStatus={resolvedRemoveProjectStatus}
/>
</View>
</View>
);
}
function ProjectCreateButton({
actionsVisible,
createButton,
}: {
actionsVisible: boolean;
createButton: SidebarProjectCreateButtonConfig;
}): ReactElement {
return (
<NewWorktreeButton
accessibilityLabel={createButton.accessibilityLabel}
icon={createButton.icon}
onPress={createButton.onPress}
visible={actionsVisible}
showShortcutHint={createButton.showShortcutHint ?? false}
testID={createButton.testID}
tooltipLabel={createButton.tooltipLabel}
/>
);
}
function useSidebarProjectRemoval({
projectKey,
projectName,
serverId,
workspaces,
}: {
projectKey: string;
projectName?: string;
serverId: string | null;
workspaces: readonly SidebarWorkspaceEntry[];
}): {
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending";
} {
const toast = useToast();
const [isRemovingProject, setIsRemovingProject] = useState(false);
const displayName = projectName ?? projectKey;
const handleRemoveProject = useCallback(() => {
if (isRemovingProject || !serverId) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Remove project?",
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Remove",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsRemovingProject(true);
const snapshots = new Map(
workspaces.map((workspace) => [
workspace.workspaceId,
hideWorkspaceOptimistically(workspace),
]),
);
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
void Promise.allSettled(
workspaces.map(async (ws) => {
try {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId,
workspaceId: ws.workspaceId,
snapshot: snapshots.get(ws.workspaceId) ?? null,
});
throw error;
}
}),
).then((results) => {
const failed = results.filter(isRejected);
if (failed.length > 0) {
toast.error("Failed to remove some workspaces");
}
setIsRemovingProject(false);
return;
});
})();
}, [displayName, isRemovingProject, serverId, toast, workspaces]);
return {
onRemoveProject: handleRemoveProject,
removeProjectStatus: isRemovingProject ? "pending" : "idle",
};
}
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: workspace.workspaceId,
});
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return snapshot;
}
function restoreOptimisticallyHiddenWorkspace(input: {
serverId: string;
workspaceId: string;
snapshot: WorkspaceDescriptor | null;
}): void {
clearWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (input.snapshot) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
}
}
function projectKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function ProjectKebabMenu({
projectKey,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
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;
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={projectKebabStyle}
accessibilityRole="button"
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${projectKey}`}
>
{renderKebabTriggerIcon}
</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}
status={removeProjectStatus}
pendingLabel="Removing..."
onSelect={onRemoveProject}
>
Remove project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function NewWorktreeButton({
accessibilityLabel,
icon,
onPress,
visible,
loading = false,
testID,
showShortcutHint = false,
tooltipLabel,
}: {
accessibilityLabel: string;
icon: "folder-plus" | "plus";
onPress: () => void;
visible: boolean;
loading?: boolean;
testID: string;
showShortcutHint?: boolean;
tooltipLabel: string;
}) {
const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.projectIconActionButton,
!visible && styles.projectIconActionButtonHidden,
(Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
],
[visible, loading],
);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onPress();
},
[onPress],
);
const renderIcon = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => {
if (loading) {
return <ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />;
}
const uniProps = hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "plus") {
return <ThemedPlus size={15} uniProps={uniProps} />;
}
return <ThemedFolderPlus size={15} uniProps={uniProps} />;
},
[icon, loading],
);
return (
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
>
{renderIcon}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>{tooltipLabel}</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
projectIconActionButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
projectIconActionButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
projectIconActionButtonHidden: {
opacity: 0,
},
projectTrailingActions: {
flexDirection: "row",
alignItems: "center",
gap: 2,
flexShrink: 0,
},
projectKebabButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
projectKebabButtonHidden: {
opacity: 0,
},
projectKebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
projectTrailingControlSlot: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
projectActionTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
projectActionTooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
projectActionTooltipShortcut: {},
}));
@@ -0,0 +1,107 @@
/**
* @vitest-environment jsdom
*/
import { render } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import {
SidebarProjectsDraggableList,
type SidebarProjectDragInfo,
} from "./sidebar-projects-draggable-list";
interface CapturedDraggableProps {
data: SidebarProjectEntry[];
onDragEnd: (next: SidebarProjectEntry[]) => void;
}
const captured: { props: CapturedDraggableProps | null } = { props: null };
vi.mock("@/components/draggable-list", () => ({
DraggableList: (props: CapturedDraggableProps) => {
captured.props = props;
return null;
},
}));
function makeProject(key: string): SidebarProjectEntry {
return {
projectKey: key,
projectName: key,
projectKind: "git",
iconWorkingDir: `/repo/${key}`,
workspaces: [],
};
}
const PROJECTS_ABC: readonly SidebarProjectEntry[] = [
makeProject("a"),
makeProject("b"),
makeProject("c"),
];
const PROJECTS_AB: readonly SidebarProjectEntry[] = [makeProject("a"), makeProject("b")];
function renderEmpty(): React.ReactElement {
return React.createElement(React.Fragment);
}
afterEach(() => {
captured.props = null;
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
});
const noopRender = (_info: SidebarProjectDragInfo): React.ReactElement => renderEmpty();
describe("SidebarProjectsDraggableList", () => {
it("persists the reordered project keys to the order store on drag end", () => {
render(
<SidebarProjectsDraggableList
projects={PROJECTS_ABC}
serverId="server-1"
renderProject={noopRender}
/>,
);
expect(captured.props).not.toBeNull();
const reordered = [PROJECTS_ABC[1], PROJECTS_ABC[0], PROJECTS_ABC[2]];
captured.props?.onDragEnd(reordered);
expect(useSidebarOrderStore.getState().getProjectOrder("server-1")).toEqual(["b", "a", "c"]);
});
it("does not persist when the reordered visible keys match the current order", () => {
useSidebarOrderStore.getState().setProjectOrder("server-1", ["a", "b"]);
const setSpy = vi.spyOn(useSidebarOrderStore.getState(), "setProjectOrder");
render(
<SidebarProjectsDraggableList
projects={PROJECTS_AB}
serverId="server-1"
renderProject={noopRender}
/>,
);
captured.props?.onDragEnd([...PROJECTS_AB]);
expect(setSpy).not.toHaveBeenCalled();
});
it("does nothing when serverId is null", () => {
render(
<SidebarProjectsDraggableList
projects={PROJECTS_AB}
serverId={null}
renderProject={noopRender}
/>,
);
captured.props?.onDragEnd([PROJECTS_AB[1], PROJECTS_AB[0]]);
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
});
});
@@ -0,0 +1,92 @@
import React, { useCallback, type MutableRefObject, type ReactElement } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { GestureType } from "react-native-gesture-handler";
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
export interface SidebarProjectDragInfo {
project: SidebarProjectEntry;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
interface SidebarProjectsDraggableListProps {
projects: readonly SidebarProjectEntry[];
serverId: string | null;
renderProject: (info: SidebarProjectDragInfo) => ReactElement;
/** Native: use the nestable variant when wrapped in a NestableScrollContainer. */
nestable?: boolean;
/** When false, the outer scroll container owns scrolling. */
scrollEnabled?: boolean;
containerStyle?: StyleProp<ViewStyle>;
testID?: string;
/** Native: gesture ref to coordinate with parent (e.g. sidebar close swipe). */
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
}
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
export function SidebarProjectsDraggableList({
projects,
serverId,
renderProject,
nestable = false,
scrollEnabled = false,
containerStyle,
testID,
simultaneousGestureRef,
}: SidebarProjectsDraggableListProps): ReactElement {
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
const handleDragEnd = useCallback(
(reorderedProjects: SidebarProjectEntry[]) => {
if (!serverId) {
return;
}
const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey);
const currentProjectOrder = getProjectOrder(serverId);
if (
!hasVisibleOrderChanged({
currentOrder: currentProjectOrder,
reorderedVisibleKeys: reorderedProjectKeys,
})
) {
return;
}
setProjectOrder(
serverId,
mergeWithRemainder({
currentOrder: currentProjectOrder,
reorderedVisibleKeys: reorderedProjectKeys,
}),
);
},
[getProjectOrder, serverId, setProjectOrder],
);
const renderItem = useCallback(
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
renderProject({ project: item, drag, isDragging: isActive, dragHandleProps }),
[renderProject],
);
return (
<DraggableList
testID={testID}
data={projects as SidebarProjectEntry[]}
keyExtractor={projectKeyExtractor}
renderItem={renderItem}
onDragEnd={handleDragEnd}
scrollEnabled={scrollEnabled}
useDragHandle
nestable={nestable}
simultaneousGestureRef={simultaneousGestureRef}
containerStyle={containerStyle}
/>
);
}
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { Fragment } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
interface SidebarShellHeaderProps {
children: ReactNode;
topSpacerStyle?: StyleProp<ViewStyle>;
toggleSlot?: ReactNode;
withTitlebarDragRegion?: boolean;
}
export function SidebarShellHeader({
children,
topSpacerStyle,
toggleSlot,
withTitlebarDragRegion = false,
}: SidebarShellHeaderProps) {
const content = (
<Fragment>
{withTitlebarDragRegion ? <TitlebarDragRegion /> : null}
{topSpacerStyle ? <View style={topSpacerStyle} /> : null}
{children}
{toggleSlot}
</Fragment>
);
if (withTitlebarDragRegion || topSpacerStyle) {
return <View style={styles.headerDragArea}>{content}</View>;
}
return content;
}
const styles = StyleSheet.create(() => ({
headerDragArea: {
position: "relative",
},
}));
@@ -0,0 +1,52 @@
import type { ReactElement, MutableRefObject } from "react";
import type { GestureType } from "react-native-gesture-handler";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { SidebarAgentListSkeleton } from "../sidebar-agent-list-skeleton";
import { SidebarWorkspaceList } from "../sidebar-workspace-list";
interface SidebarWorkspacesViewProps {
serverId: string | null;
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
isRefreshing: boolean;
collapsedProjectKeys: ReadonlySet<string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleProjectCollapsed: (projectKey: string) => void;
onRefresh: () => void;
onAddProject: () => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
}
export function SidebarWorkspacesView({
serverId,
projects,
isInitialLoad,
isRefreshing,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
onToggleProjectCollapsed,
onRefresh,
onAddProject,
onWorkspacePress,
parentGestureRef,
}: SidebarWorkspacesViewProps): ReactElement {
if (isInitialLoad) {
return <SidebarAgentListSkeleton />;
}
return (
<SidebarWorkspaceList
serverId={serverId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={onToggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isRefreshing}
onRefresh={onRefresh}
onWorkspacePress={onWorkspacePress}
onAddProject={onAddProject}
parentGestureRef={parentGestureRef}
/>
);
}
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useRef } from "react";
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useContextMenu } from "@/components/ui/context-menu";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
/**
* Shared mobile drag affordance: long-press arms a draggable-flatlist drag with haptics,
* arbitrating against vertical scroll, horizontal swipe, and an optional context-menu open.
*
* Web does not exercise this — `dragHandleProps` from `DraggableList` covers web DnD.
*/
export function useLongPressDragInteraction(input: {
drag: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const didLongPressRef = useRef(false);
const dragArmedRef = useRef(false);
const dragActivatedRef = useRef(false);
const didStartDragRef = useRef(false);
const scrollIntentRef = useRef(false);
const menuOpenedRef = useRef(false);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (dragArmTimerRef.current) {
clearTimeout(dragArmTimerRef.current);
dragArmTimerRef.current = null;
}
if (contextMenuTimerRef.current) {
clearTimeout(contextMenuTimerRef.current);
contextMenuTimerRef.current = null;
}
}, []);
const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
input.menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
});
input.menuController.setOpen(true);
menuOpenedRef.current = true;
didLongPressRef.current = true;
}, [input.menuController]);
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
}, []);
useEffect(() => {
return () => {
clearTimers();
};
}, [clearTimers]);
const armTimers = useCallback(() => {
clearTimers();
const DRAG_ARM_DELAY_MS = 180;
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
const CONTEXT_MENU_DELAY_MS = 450;
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
dragArmTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
return;
}
dragArmedRef.current = true;
dragActivatedRef.current = true;
didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {});
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || platformIsWeb) {
return;
}
contextMenuTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
return;
}
void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, input, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
if (!dragActivatedRef.current) {
return;
}
didStartDragRef.current = true;
didLongPressRef.current = true;
clearTimers();
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
},
[clearTimers],
);
const handleScrollIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
scrollIntentRef.current = true;
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handleSwipeIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
didLongPressRef.current = false;
dragArmedRef.current = false;
dragActivatedRef.current = false;
didStartDragRef.current = false;
scrollIntentRef.current = false;
menuOpenedRef.current = false;
touchStartRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
touchCurrentRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
armTimers();
},
[armTimers],
);
const handleTouchMove = useCallback(
(event: GestureResponderEvent) => {
const start = touchStartRef.current;
if (!start || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
const x = touch?.pageX;
const y = touch?.pageY;
if (typeof x !== "number" || typeof y !== "number") {
return;
}
const current = { x, y };
touchCurrentRef.current = current;
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const decision = decideLongPressMove({
dragArmed: dragArmedRef.current,
didStartDrag: didStartDragRef.current,
startPoint: start,
currentPoint: current,
});
if (decision === "vertical_scroll") {
handleScrollIntent({ dx, dy, distance });
return;
}
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
handleSwipeIntent({ dx, dy, distance });
return;
}
if (decision === "start_drag") {
handleDragIntent({ dx, dy, distance });
}
},
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
);
const handlePressOut = useCallback(() => {
clearTimers();
dragArmedRef.current = false;
dragActivatedRef.current = false;
touchStartRef.current = null;
touchCurrentRef.current = null;
}, [clearTimers]);
return {
didLongPressRef,
handleLongPress,
handlePressIn,
handleTouchMove,
handlePressOut,
};
}
@@ -7,12 +7,34 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
};
function formatUpdateMessage(ops: readonly SkillOp[]): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { theme } = useUnistyles();
@@ -25,16 +47,18 @@ export function IntegrationsSection() {
} = useCliInstall();
const {
status: skillsStatus,
isInstalling: isInstallingSkills,
isWorking: isSkillsWorking,
install: installSkills,
update: updateSkills,
uninstall: uninstallSkills,
refresh: refreshSkillsStatus,
} = useSkillsInstall();
} = useSkillsStatus();
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
refreshCliStatus();
refreshSkillsStatus();
void refreshSkillsStatus();
return undefined;
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
);
@@ -45,9 +69,33 @@ export function IntegrationsSection() {
}, [installCli, isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isInstallingSkills) return;
installSkills();
}, [installSkills, isInstallingSkills]);
if (isSkillsWorking) return;
void installSkills();
}, [installSkills, isSkillsWorking]);
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: "Update Paseo skills?",
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
confirmLabel: "Update",
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: "Uninstall Paseo skills?",
message: UNINSTALL_MESSAGE,
confirmLabel: "Uninstall",
destructive: true,
});
if (!confirmed) return;
await uninstallSkills();
}, [isSkillsWorking, uninstallSkills]);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
@@ -96,6 +144,8 @@ export function IntegrationsSection() {
return null;
}
const skillsState = skillsStatus?.state ?? null;
return (
<SettingsSection title="Integrations" trailing={trailing}>
<View style={settingsStyles.card}>
@@ -130,30 +180,69 @@ export function IntegrationsSection() {
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI
{skillsState === "drift"
? "Update available"
: "Teach your agents to orchestrate through the CLI"}
</Text>
</View>
{skillsStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
<SkillsActions
state={skillsState}
isWorking={isSkillsWorking}
onInstall={handleInstallSkills}
onUpdate={handleUpdateSkills}
onUninstall={handleUninstallSkills}
/>
</View>
</View>
</SettingsSection>
);
}
interface SkillsActionsProps {
state: SkillsStatus["state"] | null;
isWorking: boolean;
onInstall: () => void;
onUpdate: () => void;
onUninstall: () => void;
}
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
const { theme } = useUnistyles();
if (state === "up-to-date") {
return (
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
{isWorking ? "Working..." : "Update"}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
</Button>
</View>
);
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking ? "Installing..." : "Install"}
</Button>
);
}
const styles = StyleSheet.create((theme) => ({
headerLinks: {
flexDirection: "row",
@@ -174,4 +263,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actionsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
}));
@@ -220,10 +220,67 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
}
export async function installSkills(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
function parseSkillsState(value: unknown): SkillsState {
switch (value) {
case "not-installed":
case "up-to-date":
case "drift":
return value;
default:
throw new Error(`Unexpected skills status state: ${String(value)}`);
}
}
function parseSkillOp(raw: unknown): SkillOp {
if (!isRecord(raw)) {
throw new Error("Unexpected skill op response.");
}
const name = toStringOrNull(raw.name);
if (!name) throw new Error("Skill op missing name.");
switch (raw.kind) {
case "add":
return { kind: "add", name };
case "update":
return { kind: "update", name };
case "delete":
return { kind: "delete", name };
default:
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
}
}
function parseSkillsStatus(raw: unknown): SkillsStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected skills status response.");
}
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
return { state: parseSkillsState(raw.state), ops };
}
export async function getSkillsStatus(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
}
export async function installSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
}
export async function updateSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
}
export async function uninstallSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
}
@@ -5,7 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCliInstall, useSkillsInstall } from "./use-install-status";
import { useCliInstall, useSkillsStatus } from "./use-install-status";
const toast = vi.hoisted(() => ({
error: vi.fn(),
@@ -15,9 +15,11 @@ const toast = vi.hoisted(() => ({
const desktopDaemon = vi.hoisted(() => ({
getCliInstallStatus: vi.fn(),
getSkillsInstallStatus: vi.fn(),
installCli: vi.fn(),
getSkillsStatus: vi.fn(),
installSkills: vi.fn(),
updateSkills: vi.fn(),
uninstallSkills: vi.fn(),
shouldUseDesktopDaemon: vi.fn(() => true),
}));
@@ -89,11 +91,9 @@ describe("useCliInstall", () => {
});
});
describe("useSkillsInstall", () => {
describe("useSkillsStatus", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
});
afterEach(() => {
@@ -101,34 +101,155 @@ describe("useSkillsInstall", () => {
vi.clearAllMocks();
});
it("loads skills install status", async () => {
const { result } = renderDesktopHook(() => useSkillsInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: true });
it("loads the current skills status", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "up-to-date",
ops: [],
});
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
expect(result.current.isWorking).toBe(false);
expect(toast.error).not.toHaveBeenCalled();
});
it("toasts and exposes skills install errors", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installSkills.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useSkillsInstall());
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
expect(result.current.status?.state).toBe("not-installed");
});
await act(async () => {
await result.current.install();
});
expect(desktopDaemon.installSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
});
it("update transitions drift to up-to-date", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "drift",
ops: [{ kind: "update", name: "paseo" }],
});
desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("drift");
});
await act(async () => {
await result.current.update();
});
expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
});
it("uninstall transitions up-to-date back to not-installed", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
desktopDaemon.uninstallSkills.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("up-to-date");
});
await act(async () => {
await result.current.uninstall();
});
expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
});
});
it("isWorking flips while a mutation is in flight", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
let resolveInstall: ((value: unknown) => void) | null = null;
desktopDaemon.installSkills.mockImplementation(
() =>
new Promise((resolve) => {
resolveInstall = resolve;
}),
);
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed");
});
expect(result.current.isWorking).toBe(false);
let installPromise: Promise<void> = Promise.resolve();
act(() => {
result.current.install();
installPromise = result.current.install();
});
await waitFor(() => {
expect(result.current.isWorking).toBe(true);
});
await act(async () => {
resolveInstall?.({ state: "up-to-date", ops: [] });
await installPromise;
});
await waitFor(() => {
expect(result.current.isWorking).toBe(false);
});
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
it("toasts and exposes errors when install fails", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
desktopDaemon.installSkills.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed");
});
await act(async () => {
await result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
});
@@ -2,11 +2,14 @@ import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCliInstallStatus,
getSkillsInstallStatus,
getSkillsStatus,
installCli,
installSkills,
shouldUseDesktopDaemon,
type InstallStatus,
type SkillsStatus,
uninstallSkills,
updateSkills,
} from "@/desktop/daemon/desktop-daemon";
import {
useDesktopIpcErrorReporter,
@@ -14,11 +17,7 @@ import {
} from "@/desktop/hooks/desktop-ipc-error";
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
"desktop",
"integrations",
"skills-install-status",
] as const;
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
interface DesktopInstallHookResult {
status: InstallStatus | null;
@@ -77,25 +76,43 @@ export function useCliInstall(): DesktopInstallHookResult {
};
}
export function useSkillsInstall(): DesktopInstallHookResult {
export interface SkillsStatusHookResult {
status: SkillsStatus | null;
isLoading: boolean;
isWorking: boolean;
error: Error | null;
refresh: () => Promise<void>;
install: () => Promise<void>;
update: () => Promise<void>;
uninstall: () => Promise<void>;
}
export function useSkillsStatus(): SkillsStatusHookResult {
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<InstallStatus, Error>({
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
queryFn: getSkillsInstallStatus,
const statusQuery = useQuery<SkillsStatus, Error>({
queryKey: SKILLS_STATUS_QUERY_KEY,
queryFn: getSkillsStatus,
enabled,
retry: false,
});
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check orchestration skills install status.",
message: "Unable to check orchestration skills status.",
logLabel: "[Integrations] Failed to load skills status",
});
const installMutation = useMutation<InstallStatus, Error>({
const setStatus = useCallback(
(next: SkillsStatus) => {
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
},
[queryClient],
);
const installMutation = useMutation<SkillsStatus, Error>({
mutationFn: installSkills,
onError: (error) => {
reportError({
@@ -104,23 +121,65 @@ export function useSkillsInstall(): DesktopInstallHookResult {
logLabel: "[Integrations] Failed to install skills",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
},
onSuccess: setStatus,
});
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const refresh = useCallback(() => {
void refetch();
const updateMutation = useMutation<SkillsStatus, Error>({
mutationFn: updateSkills,
onError: (error) => {
reportError({
error,
message: "Unable to update orchestration skills.",
logLabel: "[Integrations] Failed to update skills",
});
},
onSuccess: setStatus,
});
const uninstallMutation = useMutation<SkillsStatus, Error>({
mutationFn: uninstallSkills,
onError: (error) => {
reportError({
error,
message: "Unable to uninstall orchestration skills.",
logLabel: "[Integrations] Failed to uninstall skills",
});
},
onSuccess: setStatus,
});
const isWorking =
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
const install = useCallback(async () => {
await installMutation.mutateAsync().catch(() => undefined);
}, [installMutation]);
const update = useCallback(async () => {
await updateMutation.mutateAsync().catch(() => undefined);
}, [updateMutation]);
const uninstall = useCallback(async () => {
await uninstallMutation.mutateAsync().catch(() => undefined);
}, [uninstallMutation]);
return {
status: installStatus ?? null,
status: status ?? null,
isLoading,
isInstalling,
error: statusError ?? installError ?? null,
install,
isWorking,
error:
statusError ??
installMutation.error ??
updateMutation.error ??
uninstallMutation.error ??
null,
refresh,
install,
update,
uninstall,
};
}
@@ -184,21 +184,6 @@ describe("UpdateCalloutSource", () => {
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
});
it("shows only the changelog action once the update is installed", async () => {
updaterState.value = {
...updaterState.value,
status: "installed",
availableUpdate: null,
};
await renderHarness(root!);
expect(container?.textContent).toContain("Update installed");
expect(
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
).toContain("What's new");
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
});
it("disables the install action and shows Installing... while installing", async () => {
updaterState.value = {
...updaterState.value,
@@ -13,25 +13,18 @@ import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
function resolveUpdateCalloutTitle(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
}): string {
if (args.isInstalled) return "Update installed";
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
if (args.isInstalling) return "Installing update";
if (args.isError) return "Update failed";
return "Update available";
}
function resolveUpdateCalloutDescription(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
errorMessage: string | null;
latestVersion: string | undefined;
}): ReactNode {
if (args.isInstalled) return "Restart to use the new version.";
if (args.isInstalling) return "Installing and restarting...";
if (args.isError) return args.errorMessage ?? "Something went wrong.";
if (args.latestVersion) {
@@ -43,7 +36,6 @@ function resolveUpdateCalloutDescription(args: {
}
function buildUpdateCalloutActions(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
openChangelog: () => void;
@@ -53,7 +45,7 @@ function buildUpdateCalloutActions(args: {
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
if (args.isError) {
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
} else if (!args.isInstalled) {
} else {
actions.push({
label: args.isInstalling ? "Installing..." : "Install & restart",
onPress: args.install,
@@ -107,29 +99,21 @@ export function UpdateCalloutSource() {
if (!isDesktopApp) {
return;
}
if (
status !== "available" &&
status !== "installed" &&
status !== "installing" &&
status !== "error"
) {
if (status !== "available" && status !== "installing" && status !== "error") {
return;
}
const isInstalled = status === "installed";
const isError = status === "error";
const isAvailable = !isInstalled && !isInstalling && !isError;
const isAvailable = !isInstalling && !isError;
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
const description = resolveUpdateCalloutDescription({
isInstalled,
isInstalling,
isError,
errorMessage,
latestVersion: availableUpdate?.latestVersion ?? undefined,
});
const actions = buildUpdateCalloutActions({
isInstalled,
isInstalling,
isError,
openChangelog,
@@ -0,0 +1,246 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useAggregatedAgentIds, useAggregatedAgents } from "./use-aggregated-agents";
const { mockRuntimeStore } = vi.hoisted(() => ({
mockRuntimeStore: {
subscribeAll: vi.fn(() => () => undefined),
getVersion: vi.fn(() => 1),
getSnapshot: vi.fn(() => ({ agentDirectoryStatus: "idle" })),
refreshAllAgentDirectories: vi.fn(),
},
}));
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => mockRuntimeStore,
useHosts: () => [{ serverId: "server-1", label: "Local" }],
}));
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "idle",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/tmp/project",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input: Partial<Agent>): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function seedAgents(agents: Agent[]) {
act(() => {
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
useSessionStore
.getState()
.setAgents("server-1", new Map(agents.map((agent) => [agent.id, agent])));
});
}
afterEach(() => {
mockRuntimeStore.subscribeAll.mockClear();
mockRuntimeStore.getVersion.mockClear();
mockRuntimeStore.getSnapshot.mockClear();
mockRuntimeStore.refreshAllAgentDirectories.mockClear();
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("useAggregatedAgents", () => {
it("sorts by createdAt descending by default and falls back to id for ties", () => {
seedAgents([
makeAgent({
id: "beta",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
}),
makeAgent({
id: "newest",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
}),
makeAgent({
id: "alpha",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
}),
]);
const { result } = renderHook(() => useAggregatedAgents());
expect(result.current.agents.map((agent) => agent.id)).toEqual(["newest", "alpha", "beta"]);
});
it("can preserve the legacy running-then-activity sort", () => {
seedAgents([
makeAgent({
id: "idle-newer",
status: "idle",
lastActivityAt: new Date("2026-05-08T12:00:00.000Z"),
}),
makeAgent({
id: "running-older",
status: "running",
lastActivityAt: new Date("2026-05-08T09:00:00.000Z"),
}),
makeAgent({
id: "running-newer",
status: "running",
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
}),
]);
const { result } = renderHook(() => useAggregatedAgents({ sort: "running-then-activity" }));
expect(result.current.agents.map((agent) => agent.id)).toEqual([
"running-newer",
"running-older",
"idle-newer",
]);
});
});
describe("useAggregatedAgentIds", () => {
it("keeps the same id array reference when membership and order are unchanged", () => {
seedAgents([
makeAgent({
id: "alpha",
title: "Alpha",
status: "idle",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
lastActivityAt: new Date("2026-05-08T10:00:00.000Z"),
}),
makeAgent({
id: "beta",
title: "Beta",
status: "running",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
}),
]);
const { result } = renderHook(() => useAggregatedAgentIds());
const before = result.current;
act(() => {
useSessionStore.getState().setAgents(
"server-1",
new Map([
[
"alpha",
makeAgent({
id: "alpha",
title: "Renamed Alpha",
status: "running",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
lastActivityAt: new Date("2026-05-08T12:00:00.000Z"),
}),
],
[
"beta",
makeAgent({
id: "beta",
title: "Renamed Beta",
status: "idle",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
lastActivityAt: new Date("2026-05-08T12:30:00.000Z"),
}),
],
]),
);
});
expect(result.current).toBe(before);
expect(result.current).toEqual(["beta", "alpha"]);
});
it("changes the id array reference when membership changes", () => {
seedAgents([makeAgent({ id: "alpha" })]);
const { result } = renderHook(() => useAggregatedAgentIds());
const before = result.current;
act(() => {
useSessionStore.getState().setAgents(
"server-1",
new Map([
["alpha", makeAgent({ id: "alpha" })],
["beta", makeAgent({ id: "beta", createdAt: new Date("2026-05-08T11:00:00.000Z") })],
]),
);
});
expect(result.current).not.toBe(before);
expect(result.current).toEqual(["beta", "alpha"]);
});
it("keeps the same id array reference when a local predicate still yields the same ids", () => {
seedAgents([
makeAgent({ id: "mapped", cwd: "/tmp/project" }),
makeAgent({
id: "unmapped",
cwd: "/tmp/missing",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
}),
]);
const { result } = renderHook(() =>
useAggregatedAgentIds({
filter: (agent) => agent.cwd === "/tmp/project" && !agent.archivedAt,
}),
);
const before = result.current;
act(() => {
useSessionStore.getState().setAgents(
"server-1",
new Map([
["mapped", makeAgent({ id: "mapped", cwd: "/tmp/project", title: "Renamed" })],
[
"unmapped",
makeAgent({
id: "unmapped",
cwd: "/tmp/missing",
title: "Still hidden",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
}),
],
]),
);
});
expect(result.current).toBe(before);
expect(result.current).toEqual(["mapped"]);
});
});
+143 -16
View File
@@ -1,5 +1,6 @@
import { useMemo, useCallback, useSyncExternalStore } from "react";
import { useShallow } from "zustand/shallow";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
@@ -18,12 +19,151 @@ export interface AggregatedAgentsResult {
refreshAll: () => void;
}
export type AggregatedAgentSortOption = "createdAt-desc-stable" | "running-then-activity";
export interface AggregatedAgentIdEntry {
id: string;
serverId: string;
status: Agent["status"];
lastActivityAt: Date;
cwd: string;
archivedAt: Date | null;
createdAt: Date;
}
interface AggregatedAgentSortFields {
id: string;
status: Agent["status"];
lastActivityAt: Date;
createdAt: Date;
}
function compareAggregatedAgentsByCreatedAtDescStable(
left: AggregatedAgentSortFields,
right: AggregatedAgentSortFields,
): number {
const createdAtDiff = right.createdAt.getTime() - left.createdAt.getTime();
if (createdAtDiff !== 0) {
return createdAtDiff;
}
return left.id.localeCompare(right.id);
}
function compareAggregatedAgentsByRunningThenActivity(
left: AggregatedAgentSortFields,
right: AggregatedAgentSortFields,
): number {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
if (leftRunning && !rightRunning) {
return -1;
}
if (!leftRunning && rightRunning) {
return 1;
}
const leftTime = left.lastActivityAt.getTime();
const rightTime = right.lastActivityAt.getTime();
return rightTime - leftTime;
}
function sortAggregatedAgents(
agents: AggregatedAgent[],
sort: AggregatedAgentSortOption,
): AggregatedAgent[] {
agents.sort(
sort === "running-then-activity"
? compareAggregatedAgentsByRunningThenActivity
: compareAggregatedAgentsByCreatedAtDescStable,
);
return agents;
}
function sortAggregatedAgentIdEntries(
agents: AggregatedAgentIdEntry[],
sort: AggregatedAgentSortOption,
): AggregatedAgentIdEntry[] {
agents.sort(
sort === "running-then-activity"
? compareAggregatedAgentsByRunningThenActivity
: compareAggregatedAgentsByCreatedAtDescStable,
);
return agents;
}
export function useAggregatedAgentIds(options?: {
includeArchived?: boolean;
sort?: AggregatedAgentSortOption;
filter?: (agent: AggregatedAgentIdEntry) => boolean;
}): string[] {
const includeArchived = options?.includeArchived ?? false;
const sort = options?.sort ?? "createdAt-desc-stable";
const filter = options?.filter;
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const agents: AggregatedAgentIdEntry[] = [];
for (const [serverId, session] of Object.entries(state.sessions)) {
for (const agent of session.agents.values()) {
if (!includeArchived && agent.archivedAt) {
continue;
}
const entry: AggregatedAgentIdEntry = {
id: agent.id,
serverId,
status: agent.status,
lastActivityAt: agent.lastActivityAt,
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
createdAt: agent.createdAt,
};
if (!filter || filter(entry)) {
agents.push(entry);
}
}
}
return sortAggregatedAgentIdEntries(agents, sort).map((agent) => agent.id);
},
shallow,
);
}
export function useAggregatedAgentsInitialLoad(): boolean {
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const agentCount = useSessionStore((state) => {
let count = 0;
for (const session of Object.values(state.sessions)) {
for (const agent of session.agents.values()) {
if (!agent.archivedAt) {
count += 1;
}
}
}
return count;
});
void runtimeVersion;
const isLoading = daemons.some((daemon) => {
const status = runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
return status === "initial_loading" || status === "revalidating";
});
return isLoading && agentCount === 0;
}
export function useAggregatedAgents(options?: {
includeArchived?: boolean;
sort?: AggregatedAgentSortOption;
}): AggregatedAgentsResult {
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const includeArchived = options?.includeArchived ?? false;
const sort = options?.sort ?? "createdAt-desc-stable";
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
@@ -83,20 +223,7 @@ export function useAggregatedAgents(options?: {
}
}
// Sort by: running agents first, then by most recent activity
allAgents.sort((left, right) => {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
if (leftRunning && !rightRunning) {
return -1;
}
if (!leftRunning && rightRunning) {
return 1;
}
const leftTime = left.lastActivityAt.getTime();
const rightTime = right.lastActivityAt.getTime();
return rightTime - leftTime;
});
sortAggregatedAgents(allAgents, sort);
// Check if we have any cached data
const hasAnyData = allAgents.length > 0;
@@ -116,7 +243,7 @@ export function useAggregatedAgents(options?: {
isInitialLoad,
isRevalidating,
};
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents, sort]);
return {
...result,
@@ -1,7 +1,16 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { __private__ } from "./use-all-agents-list";
import type { Agent } from "@/stores/session-store";
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => ({
refreshAgentDirectory: vi.fn(),
}),
useHostRuntimeConnectionStatus: () => "online",
useHostRuntimeIsDirectoryLoading: () => false,
useHosts: () => [{ serverId: "server-1", label: "Local" }],
}));
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
@@ -1,10 +1,20 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { pickDirectory } from "@/desktop/pick-directory";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
useKeyboardShortcutsStore,
type ProjectPickerIntent,
} from "@/stores/keyboard-shortcuts-store";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import { useIsLocalDaemon } from "./use-is-local-daemon";
import { useOpenProject } from "./use-open-project";
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
const OPEN_PROJECT_INTENT: ProjectPickerIntent = { kind: "open-project" };
export function useOpenProjectPicker(
serverId: string | null,
intent: ProjectPickerIntent = OPEN_PROJECT_INTENT,
): () => Promise<void> {
const normalizedServerId = serverId?.trim() ?? "";
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
@@ -16,7 +26,7 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise<voi
}
if (!isLocalDaemon) {
setProjectPickerOpen(true);
setProjectPickerOpen(true, intent);
return;
}
@@ -25,6 +35,15 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise<voi
return;
}
if (intent.kind === "new-workspace") {
router.navigate(
buildHostNewWorkspaceRoute(normalizedServerId, path, {
headerTitle: intent.headerTitle,
}) as never,
);
return;
}
await openProject(path);
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
}, [intent, isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
}
@@ -37,6 +37,7 @@ interface NewWorkspaceScreenProps {
serverId: string;
sourceDirectory: string;
displayName?: string;
headerTitle?: string;
}
interface PickerOptionData {
@@ -329,6 +330,11 @@ function computeWorkspaceTitle(
);
}
function resolveNewWorkspaceHeaderTitle(headerTitle: string | undefined): string {
const trimmed = headerTitle?.trim();
return trimmed ? trimmed : "New workspace";
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
const {
serverId,
@@ -385,6 +391,7 @@ export function NewWorkspaceScreen({
serverId,
sourceDirectory,
displayName: displayNameProp,
headerTitle: headerTitleProp,
}: NewWorkspaceScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -412,6 +419,7 @@ export function NewWorkspaceScreen({
}, [pickerSearchQuery]);
const displayName = displayNameProp?.trim() ?? "";
const headerTitle = resolveNewWorkspaceHeaderTitle(headerTitleProp);
const workspace = createdWorkspace;
const selectedItem = pickerSelection?.item ?? null;
const isPending = pendingAction !== null;
@@ -711,7 +719,7 @@ export function NewWorkspaceScreen({
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
New workspace
{headerTitle}
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{workspaceTitle}
+13 -10
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
@@ -745,16 +745,19 @@ function SettingsSidebar({
) : null}
<View style={sidebarStyles.list}>
{items.map((item) => (
<SidebarSectionButton
key={item.id}
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
<Fragment key={item.id}>
<SidebarSectionButton
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
@@ -27,6 +28,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
@@ -38,6 +40,7 @@ interface OpenTarget {
id: string;
label: string;
icon: ReactElement;
requiresLocalDaemon: boolean;
onOpen: () => Promise<void> | void;
}
@@ -82,14 +85,16 @@ export function WorkspaceOpenInEditorButton({
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadTargets =
const shouldQueryWorkspace =
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],
enabled: shouldLoadTargets,
enabled: shouldLoadEditorTargets,
staleTime: 60_000,
retry: false,
queryFn: async () => {
@@ -112,7 +117,7 @@ export function WorkspaceOpenInEditorButton({
const { status: checkoutStatus } = useCheckoutStatusQuery({
serverId,
cwd: shouldLoadTargets ? cwd : "",
cwd: shouldQueryWorkspace ? cwd : "",
});
const editorTargets = useMemo<OpenTarget[]>(
@@ -121,6 +126,7 @@ export function WorkspaceOpenInEditorButton({
id: editor.id,
label: editor.label,
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: true,
onOpen: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -149,13 +155,20 @@ export function WorkspaceOpenInEditorButton({
id: "github",
label: "GitHub",
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: false,
onOpen: () => openExternalUrl(url),
};
}, [checkoutStatus]);
const targets = useMemo(
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
[editorTargets, githubTarget],
() =>
filterTargetsForDaemonLocation(
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
{
isLocalDaemon,
},
),
[editorTargets, githubTarget, isLocalDaemon],
);
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
@@ -210,7 +223,7 @@ export function WorkspaceOpenInEditorButton({
}
}, [primaryOption, handleOpenTarget]);
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
return null;
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
describe("filterTargetsForDaemonLocation", () => {
const targets = [
{ id: "cursor", requiresLocalDaemon: true },
{ id: "vscode", requiresLocalDaemon: true },
{ id: "github", requiresLocalDaemon: false },
];
it("keeps local app targets and URL targets for the local daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
});
it("hides local app targets for a remote daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
{ id: "github", requiresLocalDaemon: false },
]);
});
it("preserves target order after filtering", () => {
expect(
filterTargetsForDaemonLocation(
[
{ id: "github", requiresLocalDaemon: false },
{ id: "finder", requiresLocalDaemon: true },
{ id: "docs", requiresLocalDaemon: false },
],
{ isLocalDaemon: false },
),
).toEqual([
{ id: "github", requiresLocalDaemon: false },
{ id: "docs", requiresLocalDaemon: false },
]);
});
});
@@ -0,0 +1,13 @@
export interface WorkspaceOpenTargetAvailability {
requiresLocalDaemon: boolean;
}
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
targets: readonly Target[],
input: { isLocalDaemon: boolean },
): Target[] {
if (input.isLocalDaemon) {
return [...targets];
}
return targets.filter((target) => !target.requiresLocalDaemon);
}
@@ -0,0 +1,179 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useSidebarSessionsController } from "./controller";
describe("useSidebarSessionsController", () => {
it("starts with project grouping off and no expanded projects", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
expect(result.current.groupByProject).toBe(false);
expect(result.current.previewExpandedProjects.size).toBe(0);
});
it("enables project grouping without expanding projects", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setGroupByProject(true);
});
expect(result.current.groupByProject).toBe(true);
expect(result.current.previewExpandedProjects.size).toBe(0);
});
it("toggles one expanded project", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.toggleProjectPreviewExpanded("p1");
});
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
act(() => {
result.current.toggleProjectPreviewExpanded("p1");
});
expect(result.current.previewExpandedProjects.size).toBe(0);
});
it("toggles expanded projects independently", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.toggleProjectPreviewExpanded("p1");
result.current.toggleProjectPreviewExpanded("p2");
});
expect([...result.current.previewExpandedProjects].sort()).toEqual(["p1", "p2"]);
act(() => {
result.current.toggleProjectPreviewExpanded("p1");
});
expect([...result.current.previewExpandedProjects]).toEqual(["p2"]);
});
it("clears expanded projects when project grouping is disabled", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setGroupByProject(true);
result.current.toggleProjectPreviewExpanded("p1");
});
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
act(() => {
result.current.setGroupByProject(false);
});
expect(result.current.groupByProject).toBe(false);
expect(result.current.previewExpandedProjects.size).toBe(0);
});
it("resets the session filter when the active host changes", () => {
const { result, rerender } = renderHook(
({ serverId }) => useSidebarSessionsController({ serverId }),
{ initialProps: { serverId: "server-1" as string | null } },
);
act(() => {
result.current.setSidebarSessionFilter({
type: "project",
projectKey: "project-a",
});
});
expect(result.current.sidebarSessionFilter).toEqual({
type: "project",
projectKey: "project-a",
});
rerender({ serverId: "server-2" });
expect(result.current.sidebarSessionFilter).toEqual({ type: "all" });
expect(result.current.sidebarViewMode).toBe("workspaces");
});
it("clears expanded projects when the active host changes", () => {
const { result, rerender } = renderHook(
({ serverId }) => useSidebarSessionsController({ serverId }),
{ initialProps: { serverId: "server-1" as string | null } },
);
act(() => {
result.current.setGroupByProject(true);
result.current.toggleProjectPreviewExpanded("p1");
});
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
rerender({ serverId: "server-2" });
expect(result.current.previewExpandedProjects.size).toBe(0);
});
it("clears expanded projects when the session filter changes", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setGroupByProject(true);
result.current.toggleProjectPreviewExpanded("p1");
});
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
act(() => {
result.current.setSidebarSessionFilter({
type: "project",
projectKey: "project-a",
});
});
expect(result.current.sidebarSessionFilter).toEqual({
type: "project",
projectKey: "project-a",
});
expect(result.current.previewExpandedProjects.size).toBe(0);
});
it("does not clear the session filter when project grouping is enabled", () => {
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
act(() => {
result.current.setSidebarSessionFilter({
type: "project",
projectKey: "project-a",
});
});
act(() => {
result.current.setGroupByProject(true);
});
expect(result.current.groupByProject).toBe(true);
expect(result.current.sidebarSessionFilter).toEqual({
type: "project",
projectKey: "project-a",
});
});
it("preserves project grouping when the active host changes", () => {
const { result, rerender } = renderHook(
({ serverId }) => useSidebarSessionsController({ serverId }),
{ initialProps: { serverId: "server-1" as string | null } },
);
act(() => {
result.current.setGroupByProject(true);
});
rerender({ serverId: "server-2" });
expect(result.current.groupByProject).toBe(true);
});
});
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
export function useSidebarSessionsController({ serverId }: { serverId: string | null }) {
const previousServerIdRef = useRef(serverId);
const [sidebarViewMode, setSidebarViewMode] = useState<SidebarSessionViewMode>("workspaces");
const [sidebarSessionFilter, setSidebarSessionFilter] = useState<SidebarSessionFilter>({
type: "all",
});
const [groupByProject, setGroupByProjectState] = useState(false);
const [previewExpandedProjects, setPreviewExpandedProjects] = useState<ReadonlySet<string>>(
() => new Set(),
);
const setGroupByProject = useCallback((next: boolean) => {
setGroupByProjectState(next);
if (!next) {
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
}
}, []);
const setSidebarSessionFilterWithReset = useCallback((next: SidebarSessionFilter) => {
setSidebarSessionFilter(next);
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
}, []);
const toggleProjectPreviewExpanded = useCallback((projectKey: string) => {
setPreviewExpandedProjects((previous) => {
const next = new Set(previous);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return next;
});
}, []);
useEffect(() => {
if (previousServerIdRef.current === serverId) {
return;
}
previousServerIdRef.current = serverId;
setSidebarSessionFilter({ type: "all" });
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
}, [serverId]);
return {
sidebarViewMode,
setSidebarViewMode,
sidebarSessionFilter,
setSidebarSessionFilter: setSidebarSessionFilterWithReset,
groupByProject,
previewExpandedProjects,
setGroupByProject,
toggleProjectPreviewExpanded,
};
}
@@ -0,0 +1,286 @@
import React, { memo, useCallback, useMemo, useState, type ReactElement, type Ref } from "react";
import { Pressable, Text, View } from "react-native";
import { router, type Href } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { SidebarProjectSection } from "@/components/sidebar/sidebar-collapsible-project-section";
import {
SidebarProjectTrailingActions,
type SidebarProjectCreateButtonConfig,
} from "@/components/sidebar/sidebar-project-trailing-actions";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import {
deriveGroupedSidebarSessions,
type SidebarSessionAgentProject,
type SidebarSessionGroup,
} from "./session-filtering";
import type { ResolvedSidebarSessionProject } from "./types";
export const GROUPED_SESSION_LIMIT = 6;
type SessionStoreState = ReturnType<typeof useSessionStore.getState>;
type ResolveCwdToProject = (cwd: string) => ResolvedSidebarSessionProject | null;
export function useOrderedAgentProjectShape(input: {
orderedIds: readonly string[];
serverId: string;
resolveCwdToProject: ResolveCwdToProject;
}): readonly SidebarSessionAgentProject[] {
const { orderedIds, resolveCwdToProject, serverId } = input;
const selector = useMemo(() => {
let previousById = new Map<string, SidebarSessionAgentProject>();
return (state: SessionStoreState) => {
const agents = state.sessions[serverId]?.agents;
if (!agents) {
previousById = new Map();
return [];
}
const nextById = new Map<string, SidebarSessionAgentProject>();
const agentsWithProjects: SidebarSessionAgentProject[] = [];
for (const id of orderedIds) {
const agent = agents.get(id);
if (!agent) {
continue;
}
const project = resolveCwdToProject(agent.cwd);
if (!project) {
continue;
}
const previous = previousById.get(id);
const next =
previous && previous.projectKey === project.projectKey
? previous
: { id, projectKey: project.projectKey };
nextById.set(id, next);
agentsWithProjects.push(next);
}
previousById = nextById;
return agentsWithProjects;
};
}, [orderedIds, resolveCwdToProject, serverId]);
return useStoreWithEqualityFn(useSessionStore, selector, shallow);
}
export function useGroupedSidebarSessionGroups(input: {
agentsWithProjects: readonly SidebarSessionAgentProject[];
projects: readonly SidebarProjectEntry[];
previewExpandedProjects: ReadonlySet<string>;
collapsedProjectKeys: ReadonlySet<string>;
}): readonly SidebarSessionGroup[] {
return useMemo(
() =>
deriveGroupedSidebarSessions({
agentsWithProjects: input.agentsWithProjects,
projects: input.projects,
previewExpandedProjects: input.previewExpandedProjects,
collapsedProjectKeys: input.collapsedProjectKeys,
limit: GROUPED_SESSION_LIMIT,
}),
[
input.agentsWithProjects,
input.collapsedProjectKeys,
input.previewExpandedProjects,
input.projects,
],
);
}
export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader({
serverId,
projectKey,
projectName,
projectIconKey,
workspaces,
isCollapsed,
isDragging = false,
drag,
dragHandleProps,
onToggleCollapsed,
children,
footer = null,
}: {
serverId: string | null;
projectKey: string;
projectName: string;
projectIconKey: string | null;
workspaces: readonly SidebarWorkspaceEntry[];
isCollapsed: boolean;
isDragging?: boolean;
drag?: () => void;
dragHandleProps?: DraggableListDragHandleProps;
onToggleCollapsed: (projectKey: string) => void;
children?: React.ReactNode;
footer?: React.ReactNode;
}): ReactElement {
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" });
const dataUri = useMemo(() => {
if (!icon || !icon.mimeType || !icon.data) {
return null;
}
return `data:${icon.mimeType};base64,${icon.data}`;
}, [icon]);
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const sourceDirectory = projectIconKey ?? undefined;
const handleCreateSessionPress = useCallback(() => {
if (!serverId || !sourceDirectory) {
return;
}
router.navigate(
buildHostNewWorkspaceRoute(serverId, sourceDirectory, {
headerTitle: "New session",
}) as Href,
);
}, [serverId, sourceDirectory]);
const createButton = useMemo<SidebarProjectCreateButtonConfig | null>(
() =>
serverId && sourceDirectory
? {
onPress: handleCreateSessionPress,
accessibilityLabel: "New session",
testID: `sidebar-project-${projectKey}-new-session-button`,
tooltipLabel: "New session",
icon: "plus" as const,
}
: null,
[handleCreateSessionPress, projectKey, serverId, sourceDirectory],
);
const interaction = useLongPressDragInteraction({
drag: drag ?? noopDrag,
menuController: null,
});
const trailingSlot = useMemo(
() => (
<SidebarProjectTrailingActions
projectKey={projectKey}
projectName={projectName}
serverId={serverId}
isHovered={isHovered}
workspaces={workspaces}
createButton={createButton}
/>
),
[createButton, isHovered, projectKey, projectName, serverId, workspaces],
);
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
interaction.didLongPressRef.current = false;
return;
}
onToggleCollapsed(projectKey);
}, [interaction.didLongPressRef, onToggleCollapsed, projectKey]);
const headerProps = useMemo(
() => ({
accessibilityLabel: isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`,
onPressIn: drag ? interaction.handlePressIn : undefined,
onTouchMove: drag ? interaction.handleTouchMove : undefined,
onPressOut: drag ? interaction.handlePressOut : undefined,
}),
[
drag,
interaction.handlePressIn,
interaction.handlePressOut,
interaction.handleTouchMove,
isCollapsed,
projectName,
],
);
return (
<View
{...dragHandleProps?.attributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<SidebarProjectSection
projectKey={projectKey}
projectName={projectName}
iconDataUri={dataUri}
chevron={isCollapsed ? "expand" : "collapse"}
isHovered={isHovered}
isDragging={isDragging}
trailingSlot={trailingSlot}
onPress={handlePress}
testID={`sidebar-session-group-header-${projectKey}`}
isCollapsed={isCollapsed}
footer={footer}
headerProps={headerProps}
>
{children}
</SidebarProjectSection>
</View>
);
});
function noopDrag() {}
export const SidebarSessionGroupFooter = memo(function SidebarSessionGroupFooter({
projectKey,
hiddenCount,
isExpanded,
onPress,
}: {
projectKey: string;
hiddenCount: number;
isExpanded: boolean;
onPress: (projectKey: string) => void;
}): ReactElement {
const handlePress = useCallback(() => {
onPress(projectKey);
}, [onPress, projectKey]);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={isExpanded ? "Show less sessions" : `Show ${hiddenCount} more sessions`}
onPress={handlePress}
style={footerRowStyle}
testID={`sidebar-session-group-footer-${projectKey}`}
>
<Text style={styles.footerText}>{isExpanded ? "Show less" : `Show ${hiddenCount} more`}</Text>
</Pressable>
);
});
const styles = StyleSheet.create((theme) => ({
footerRow: {
minHeight: 32,
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[1],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
},
footerText: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
}));
const footerRowStyle = styles.footerRow;
@@ -0,0 +1,4 @@
export { useSidebarSessionsController } from "./controller";
export { SidebarSessionsToggle } from "./toggle";
export { SidebarSessionsView } from "./view";
export type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
@@ -0,0 +1,432 @@
/**
* @vitest-environment jsdom
*/
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
import React from "react";
import { useCallback, useMemo } from "react";
import { Pressable, View } from "react-native";
import { afterEach, describe, expect, it, vi } from "vitest";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import type { DaemonClient } from "@server/client/daemon-client";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
SidebarSessionGroupFooter,
SidebarSessionGroupHeader,
useGroupedSidebarSessionGroups,
useOrderedAgentProjectShape,
} from "./grouped-view";
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
function gitProject(key: string): SidebarProjectEntry {
return {
projectKey: key,
projectName: `Project ${key}`,
projectKind: "git",
iconWorkingDir: `/repo/${key}`,
workspaces: [
{
workspaceKey: `server-1:${key}-ws`,
serverId: "server-1",
workspaceId: `${key}-ws`,
projectKey: key,
projectRootPath: `/repo/${key}`,
workspaceDirectory: `/repo/${key}/ws`,
projectKind: "git",
workspaceKind: "checkout",
name: `${key}-ws`,
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
},
],
};
}
const HARNESS_PROJECTS: readonly SidebarProjectEntry[] = [
gitProject("project-a"),
gitProject("project-b"),
];
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
const { routerNavigateMock } = vi.hoisted(() => ({
routerNavigateMock: vi.fn(),
}));
vi.mock("@/hooks/use-project-icon-query", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
FolderPlus: createIcon("FolderPlus"),
MoreVertical: createIcon("MoreVertical"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
Trash2: createIcon("Trash2"),
};
});
vi.mock("expo-router", () => ({
router: {
navigate: routerNavigateMock,
},
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<View testID={testID}>{children}</View>
),
DropdownMenuTrigger: ({
children,
testID,
}: {
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
testID?: string;
}) => (
<Pressable testID={testID}>
{typeof children === "function" ? children({ hovered: false }) : children}
</Pressable>
),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => <View />,
}));
vi.mock("@/constants/platform", () => ({
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const SERVER_ID = "server-1";
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) =>
typeof styles === "function"
? styles({
borderRadius: { lg: 8, md: 6, sm: 4 },
colors: {
border: "#dddddd",
foreground: "#111111",
foregroundMuted: "#666666",
surface2: "#eeeeee",
surfaceSidebarHover: "#f5f5f5",
},
fontSize: { sm: 14, xs: 12 },
iconSize: { md: 20 },
shadow: { md: {} },
spacing: { 1: 4, 2: 8, 3: 12 },
})
: styles,
},
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
function ThemedComponent(props: Record<string, unknown>) {
return <Component {...props} />;
},
}));
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "running",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/repo/main",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input: Partial<Agent> = {}): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function seedAgent(agent: Agent) {
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
}
function seedAgents(agents: Agent[]) {
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
useSessionStore
.getState()
.setAgents(SERVER_ID, new Map(agents.map((agent) => [agent.id, agent])));
}
function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet<string> }) {
const orderedIds = useStoreWithEqualityFn(
useSessionStore,
(state) => Array.from(state.sessions[SERVER_ID]?.agents?.keys() ?? []),
shallow,
);
const resolveCwdToProject = useCallback((cwd: string) => {
if (cwd === "/repo/b") {
return { projectKey: "project-b", projectName: "Project B", projectIconKey: "/repo/b" };
}
return { projectKey: "project-a", projectName: "Project A", projectIconKey: "/repo/a" };
}, []);
const agentsWithProjects = useOrderedAgentProjectShape({
orderedIds,
serverId: SERVER_ID,
resolveCwdToProject,
});
const previewExpandedProjects = useMemo(() => new Set<string>(), []);
const fallbackCollapsed = useMemo(() => new Set<string>(), []);
const collapsedProjectKeys = input?.collapsedProjectKeys ?? fallbackCollapsed;
return useGroupedSidebarSessionGroups({
agentsWithProjects,
projects: HARNESS_PROJECTS,
previewExpandedProjects,
collapsedProjectKeys,
});
}
afterEach(() => {
cleanup();
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
routerNavigateMock.mockClear();
});
describe("sidebar session render boundaries", () => {
it("changes the row slice when lastActivityAt changes", () => {
const beforeAgent = makeAgent();
seedAgent(beforeAgent);
const beforeSlice = selectSidebarSessionSlice(
useSessionStore.getState(),
"server-1",
"agent-1",
);
const afterAgent = makeAgent({
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
});
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", afterAgent]]));
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(beforeSlice, afterSlice)).toBe(false);
});
it("does not change the row slice for fields the row does not consume", () => {
seedAgent(makeAgent());
const beforeSlice = selectSidebarSessionSlice(
useSessionStore.getState(),
"server-1",
"agent-1",
);
useSessionStore.getState().setAgents(
"server-1",
new Map([
[
"agent-1",
makeAgent({
status: "idle",
createdAt: new Date("2026-05-08T11:00:00.000Z"),
requiresAttention: true,
}),
],
]),
);
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(beforeSlice, afterSlice)).toBe(true);
});
it("keeps grouped section data stable when activity changes without reordering ids", () => {
seedAgents([makeAgent({ id: "agent-1" }), makeAgent({ id: "agent-2", cwd: "/repo/main-2" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
const beforeGroups = result.current;
act(() => {
useSessionStore.getState().setAgents(
SERVER_ID,
new Map([
[
"agent-1",
makeAgent({
id: "agent-1",
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
}),
],
["agent-2", makeAgent({ id: "agent-2", cwd: "/repo/main-2" })],
]),
);
});
expect(result.current).toBe(beforeGroups);
});
it("changes grouped section data when cwd resolves to a different project", () => {
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
const beforeGroups = result.current;
act(() => {
useSessionStore
.getState()
.setAgents(SERVER_ID, new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/b" })]]));
});
expect(result.current).not.toBe(beforeGroups);
expect(result.current[0]).toMatchObject({
projectKey: "project-b",
visibleIds: ["agent-1"],
});
});
it("keeps grouped section data stable when cwd changes but resolves to the same project", () => {
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
const beforeGroups = result.current;
act(() => {
useSessionStore
.getState()
.setAgents(
SERVER_ID,
new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/a-worktree" })]]),
);
});
expect(result.current).toBe(beforeGroups);
});
it("emits a collapsed group with no visible ids and zero hidden count", () => {
const overLimit = Array.from({ length: 10 }, (_, index) =>
makeAgent({ id: `agent-${index + 1}`, cwd: "/repo/a" }),
);
seedAgents(overLimit);
const { result } = renderHook(() =>
useGroupedBoundaryHarness({ collapsedProjectKeys: new Set(["project-a"]) }),
);
expect(result.current).toEqual([
expect.objectContaining({
projectKey: "project-a",
isCollapsed: true,
visibleIds: [],
hiddenCount: 0,
totalCount: 10,
}),
]);
});
it("calls the grouped footer press handler with its project key", () => {
const onPress = vi.fn();
const { getByTestId } = render(
<SidebarSessionGroupFooter
projectKey="project-a"
hiddenCount={2}
isExpanded={false}
onPress={onPress}
/>,
);
fireEvent.click(getByTestId("sidebar-session-group-footer-project-a"));
expect(onPress).toHaveBeenCalledWith("project-a");
});
it("renders the project kebab and new-session button in the grouped header", () => {
const { getByTestId } = render(
<SidebarSessionGroupHeader
serverId="server-1"
projectKey="project-a"
projectName="Project A"
projectIconKey="/repo/a"
workspaces={HARNESS_PROJECTS[0].workspaces}
isCollapsed={false}
onToggleCollapsed={vi.fn()}
/>,
);
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
expect(getByTestId("sidebar-project-project-a-new-session-button")).toBeTruthy();
});
it("opens a new session scoped to the grouped project root", () => {
const { getByTestId } = render(
<SidebarSessionGroupHeader
serverId="server-1"
projectKey="project-a"
projectName="Project A"
projectIconKey="/repo/a"
workspaces={HARNESS_PROJECTS[0].workspaces}
isCollapsed={false}
onToggleCollapsed={vi.fn()}
/>,
);
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
expect(routerNavigateMock).toHaveBeenCalledWith(
"/h/server-1/new?dir=%2Frepo%2Fa&title=New+session",
);
});
});
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from "vitest";
import { shallow } from "zustand/shallow";
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "running",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/tmp/project",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input: Partial<Agent> = {}): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function seedAgent(agent: Agent) {
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
useSessionStore.getState().setAgents("server-1", new Map([[agent.id, agent]]));
}
afterEach(() => {
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("selectSidebarSessionSlice", () => {
it("returns a shallow-equal slice when the agent state is unchanged", () => {
seedAgent(makeAgent());
const first = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
const second = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(first, second)).toBe(true);
expect(first).toEqual({
title: "Agent",
cwd: "/tmp/project",
provider: "codex",
lastActivityAt: TIMESTAMP,
archivedAt: null,
});
});
it("changes the row slice when lastActivityAt changes", () => {
seedAgent(makeAgent());
const before = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
useSessionStore.getState().setAgents(
"server-1",
new Map([
[
"agent-1",
makeAgent({
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
}),
],
]),
);
const after = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
expect(shallow(before, after)).toBe(false);
expect(after?.lastActivityAt).toEqual(new Date("2026-05-08T11:00:00.000Z"));
});
});
@@ -0,0 +1,27 @@
import { useSessionStore, type Agent } from "@/stores/session-store";
export type SidebarSessionSlice = {
title: string | null;
cwd: string;
provider: Agent["provider"];
lastActivityAt: Date;
archivedAt: Date | null;
} | null;
export function selectSidebarSessionSlice(
state: ReturnType<typeof useSessionStore.getState>,
serverId: string,
agentId: string,
): SidebarSessionSlice {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
if (!agent) {
return null;
}
return {
title: agent.title ?? null,
cwd: agent.cwd,
provider: agent.provider,
lastActivityAt: agent.lastActivityAt,
archivedAt: agent.archivedAt ?? null,
};
}
@@ -0,0 +1,479 @@
import { describe, expect, it } from "vitest";
import {
createSidebarSessionWorkspaceLookup,
deriveGroupedSidebarSessions,
deriveSidebarSessionFilterAvailability,
deriveSidebarSessionFilterProjects,
shouldIncludeSidebarSessionAgent,
} from "./session-filtering";
import type { SidebarSessionAgent, SidebarSessionWorkspace } from "./types";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
type ProjectKind = WorkspaceDescriptor["projectKind"];
const WORKSPACES: SidebarSessionWorkspace[] = [
{
serverId: "server-1",
workspaceId: "workspace-1",
workspaceKey: "server-1:workspace-1",
workspaceName: "Main",
projectKey: "project-a",
projectName: "Project A",
projectIconKey: "/repo/main",
workspaceDirectory: "/repo/main",
},
{
serverId: "server-1",
workspaceId: "workspace-2",
workspaceKey: "server-1:workspace-2",
workspaceName: "Docs",
projectKey: "project-b",
projectName: "Project B",
projectIconKey: "/repo/docs",
workspaceDirectory: "/repo/docs",
},
{
serverId: "server-1",
workspaceId: "workspace-3",
workspaceKey: "server-1:workspace-3",
workspaceName: "API",
projectKey: "project-a",
projectName: "Project A",
projectIconKey: "/repo/main",
workspaceDirectory: "/repo/api",
},
];
const AGENTS: SidebarSessionAgent[] = [
{ id: "main", serverId: "server-1", cwd: "/repo/main/", archivedAt: null },
{ id: "docs", serverId: "server-1", cwd: "/repo/docs", archivedAt: null },
{ id: "api", serverId: "server-1", cwd: "/repo/api", archivedAt: null },
{ id: "unmapped", serverId: "server-1", cwd: "/repo/missing", archivedAt: null },
{ id: "other-server", serverId: "server-2", cwd: "/repo/main", archivedAt: null },
{
id: "archived",
serverId: "server-1",
cwd: "/repo/main",
archivedAt: new Date("2026-05-08T10:00:00.000Z"),
},
];
function projectWorkspaceKeys(project: {
projectKey: string;
workspaces: { workspaceKey: string }[];
}) {
return {
projectKey: project.projectKey,
workspaceKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
};
}
function testProject(
key: string,
options: { name?: string; projectKind?: ProjectKind; workspaceCount?: number } = {},
): SidebarProjectEntry {
const projectKind: ProjectKind = options.projectKind ?? "git";
const workspaceCount = options.workspaceCount ?? (projectKind === "git" ? 2 : 1);
return {
projectKey: key,
projectName: options.name ?? key,
projectKind,
iconWorkingDir: `/repo/${key}`,
workspaces: Array.from({ length: workspaceCount }, (_, index) => ({
workspaceKey: `server-1:${key}-${index}`,
serverId: "server-1",
workspaceId: `${key}-${index}`,
projectKey: key,
projectRootPath: `/repo/${key}`,
workspaceDirectory: `/repo/${key}/${index}`,
projectKind,
workspaceKind: "checkout",
name: `${key}-${index}`,
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
})),
};
}
function agentProject(id: string, project: SidebarProjectEntry) {
return { id, projectKey: project.projectKey };
}
function agentProjects(ids: readonly string[], project: SidebarProjectEntry) {
return ids.map((id) => agentProject(id, project));
}
describe("sidebar session filtering", () => {
it("hides unmapped agents and includes every mapped active agent for All", () => {
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
const visibleIds = AGENTS.filter((agent) =>
shouldIncludeSidebarSessionAgent({
agent,
filter: { type: "all" },
lookup,
}),
).map((agent) => agent.id);
expect(visibleIds).toEqual(["main", "docs", "api"]);
});
it("filters by project", () => {
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
const visibleIds = AGENTS.filter((agent) =>
shouldIncludeSidebarSessionAgent({
agent,
filter: { type: "project", projectKey: "project-a" },
lookup,
}),
).map((agent) => agent.id);
expect(visibleIds).toEqual(["main", "api"]);
});
it("derives filter options only from mapped active agents", () => {
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
const availability = deriveSidebarSessionFilterAvailability({
agents: [
AGENTS[0],
AGENTS[3],
{
id: "archived-docs",
serverId: "server-1",
cwd: "/repo/docs",
archivedAt: new Date("2026-05-08T10:00:00.000Z"),
},
],
lookup,
});
expect(availability).toEqual({
projectKeys: ["project-a"],
});
const filterProjects = deriveSidebarSessionFilterProjects({
projects: [
{
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo",
workspaces: [
{
workspaceKey: "server-1:workspace-1",
serverId: "server-1",
workspaceId: "workspace-1",
projectKey: "project-a",
projectRootPath: "/repo",
workspaceDirectory: "/repo/main",
projectKind: "git",
workspaceKind: "worktree",
name: "Main",
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
},
{
workspaceKey: "server-1:workspace-3",
serverId: "server-1",
workspaceId: "workspace-3",
projectKey: "project-a",
projectRootPath: "/repo",
workspaceDirectory: "/repo/api",
projectKind: "git",
workspaceKind: "worktree",
name: "API",
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
},
],
},
{
projectKey: "project-b",
projectName: "Project B",
projectKind: "git",
iconWorkingDir: "/repo/docs",
workspaces: [
{
workspaceKey: "server-1:workspace-2",
serverId: "server-1",
workspaceId: "workspace-2",
projectKey: "project-b",
projectRootPath: "/repo/docs",
workspaceDirectory: "/repo/docs",
projectKind: "git",
workspaceKind: "worktree",
name: "Docs",
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
},
],
},
],
availability,
});
expect(filterProjects.map(projectWorkspaceKeys)).toEqual([
{
projectKey: "project-a",
workspaceKeys: ["server-1:workspace-1", "server-1:workspace-3"],
},
]);
});
});
describe("deriveGroupedSidebarSessions", () => {
it("returns no groups for empty input", () => {
expect(
deriveGroupedSidebarSessions({
agentsWithProjects: [],
projects: [],
previewExpandedProjects: new Set(),
}),
).toEqual([]);
});
it("keeps a single project under the limit fully visible and collapsed", () => {
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
limit: 6,
});
expect(groups).toEqual([
{
projectKey: "project-a",
projectName: "project-a",
projectIconKey: "/repo/project-a",
visibleIds: ["a1", "a2", "a3"],
hiddenCount: 0,
isExpanded: false,
isCollapsed: false,
totalCount: 3,
},
]);
});
it("caps a collapsed project over the limit", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
limit: 6,
});
expect(groups[0]).toMatchObject({
visibleIds: ["a1", "a2", "a3", "a4", "a5", "a6"],
hiddenCount: 4,
isExpanded: false,
isCollapsed: false,
totalCount: 10,
});
});
it("shows every id for an expanded project over the limit", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
projects: [projectA],
previewExpandedProjects: new Set(["project-a"]),
limit: 6,
});
expect(groups[0]).toMatchObject({
visibleIds: orderedIds,
hiddenCount: 0,
isExpanded: true,
isCollapsed: false,
totalCount: 10,
});
});
it("hides all rows when a project is collapsed", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
collapsedProjectKeys: new Set(["project-a"]),
limit: 6,
});
expect(groups[0]).toMatchObject({
visibleIds: [],
hiddenCount: 0,
isExpanded: false,
isCollapsed: true,
totalCount: 10,
});
});
it("collapsed wins over preview-expanded", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
projects: [projectA],
previewExpandedProjects: new Set(["project-a"]),
collapsedProjectKeys: new Set(["project-a"]),
limit: 6,
});
expect(groups[0]).toMatchObject({
visibleIds: [],
isExpanded: false,
isCollapsed: true,
});
});
it("orders projects by the canonical project list, ignoring agent first-occurrence", () => {
const projectA = testProject("project-a");
const projectB = testProject("project-b");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: [
agentProject("b1", projectB),
agentProject("a1", projectA),
agentProject("b2", projectB),
agentProject("a2", projectA),
],
projects: [projectA, projectB],
previewExpandedProjects: new Set(),
});
expect(groups.map((group) => ({ key: group.projectKey, ids: group.visibleIds }))).toEqual([
{ key: "project-a", ids: ["a1", "a2"] },
{ key: "project-b", ids: ["b1", "b2"] },
]);
});
it("preserves order within each project", () => {
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
});
expect(groups[0]?.visibleIds).toEqual(["a1", "a2", "a3"]);
});
it("skips projects that have no agents", () => {
const projectA = testProject("project-a");
const projectB = testProject("project-b");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
projects: [projectA, projectB],
previewExpandedProjects: new Set(),
});
expect(groups.map((group) => group.projectKey)).toEqual(["project-a"]);
});
it("keeps a single-workspace non-git project grouped and collapsible", () => {
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
collapsedProjectKeys: new Set(["project-a"]),
});
expect(groups[0]).toMatchObject({
projectKey: "project-a",
isCollapsed: true,
visibleIds: [],
});
});
it("renders a single-workspace non-git project group when expanded", () => {
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
});
expect(groups[0]).toMatchObject({
projectKey: "project-a",
projectName: "project-a",
isCollapsed: false,
visibleIds: ["a1", "a2"],
});
});
it("renders multi-workspace non-git project groups too", () => {
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 2 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
collapsedProjectKeys: new Set(["project-a"]),
});
expect(groups[0]).toMatchObject({
isCollapsed: true,
visibleIds: [],
});
});
it("keeps single-workspace git projects collapsible", () => {
const projectA = testProject("project-a", { projectKind: "git", workspaceCount: 1 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
collapsedProjectKeys: new Set(["project-a"]),
});
expect(groups[0]).toMatchObject({
isCollapsed: true,
});
});
it("defaults the collapsed limit to 6", () => {
const orderedIds = Array.from({ length: 7 }, (_, index) => `a${index + 1}`);
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
});
expect(groups[0]?.visibleIds).toHaveLength(6);
expect(groups[0]?.hiddenCount).toBe(1);
});
it("honors a custom collapsed limit", () => {
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2", "a3", "a4"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
limit: 2,
});
expect(groups[0]).toMatchObject({
visibleIds: ["a1", "a2"],
hiddenCount: 2,
});
});
});
@@ -0,0 +1,211 @@
import type { SidebarSessionAgent, SidebarSessionFilter, SidebarSessionWorkspace } from "./types";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export interface SidebarSessionWorkspaceLookup {
workspaceByExecutionKey: Map<string, SidebarSessionWorkspace>;
}
export interface SidebarSessionFilterAvailability {
projectKeys: readonly string[];
}
export interface SidebarSessionGroup {
projectKey: string;
projectName: string;
projectIconKey: string | null;
visibleIds: readonly string[];
hiddenCount: number;
isExpanded: boolean;
isCollapsed: boolean;
totalCount: number;
}
export interface SidebarSessionAgentProject {
id: string;
projectKey: string;
}
const DEFAULT_GROUPED_SESSION_LIMIT = 6;
function executionKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd}`;
}
export function createSidebarSessionWorkspaceLookup(
workspaces: readonly SidebarSessionWorkspace[],
): SidebarSessionWorkspaceLookup {
const workspaceByExecutionKey = new Map<string, SidebarSessionWorkspace>();
for (const workspace of workspaces) {
const normalizedDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
if (!normalizedDirectory) {
continue;
}
workspaceByExecutionKey.set(executionKey(workspace.serverId, normalizedDirectory), workspace);
}
return { workspaceByExecutionKey };
}
export function resolveSidebarSessionWorkspace(
lookup: SidebarSessionWorkspaceLookup,
agent: SidebarSessionAgent,
): SidebarSessionWorkspace | null {
const normalizedCwd = normalizeWorkspacePath(agent.cwd);
if (!normalizedCwd) {
return null;
}
return lookup.workspaceByExecutionKey.get(executionKey(agent.serverId, normalizedCwd)) ?? null;
}
export function resolveSidebarSessionWorkspaceId(input: {
agent: SidebarSessionAgent;
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
}): string | null {
const normalizedCwd = normalizeWorkspacePath(input.agent.cwd);
if (!normalizedCwd) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedCwd) {
return workspace.id;
}
}
return null;
}
export function shouldIncludeSidebarSessionAgent(input: {
agent: SidebarSessionAgent;
filter: SidebarSessionFilter;
lookup: SidebarSessionWorkspaceLookup;
}): boolean {
if (input.agent.archivedAt) {
return false;
}
const workspace = resolveSidebarSessionWorkspace(input.lookup, input.agent);
if (!workspace) {
return false;
}
if (input.filter.type === "all") {
return true;
}
return workspace.projectKey === input.filter.projectKey;
}
export function deriveSidebarSessionFilterAvailability(input: {
agents: readonly SidebarSessionAgent[];
lookup: SidebarSessionWorkspaceLookup;
}): SidebarSessionFilterAvailability {
const projectKeys = new Set<string>();
for (const agent of input.agents) {
if (agent.archivedAt) {
continue;
}
const workspace = resolveSidebarSessionWorkspace(input.lookup, agent);
if (!workspace) {
continue;
}
projectKeys.add(workspace.projectKey);
}
return {
projectKeys: Array.from(projectKeys).sort(),
};
}
export function deriveSidebarSessionFilterProjects(input: {
projects: readonly SidebarProjectEntry[];
availability: SidebarSessionFilterAvailability;
}): SidebarProjectEntry[] {
if (input.projects.length === 0 || input.availability.projectKeys.length === 0) {
return [];
}
const visibleProjectKeys = new Set(input.availability.projectKeys);
const projects: SidebarProjectEntry[] = [];
for (const project of input.projects) {
if (!visibleProjectKeys.has(project.projectKey)) {
continue;
}
projects.push(project);
}
return projects;
}
export function deriveGroupedSidebarSessions(input: {
agentsWithProjects: readonly SidebarSessionAgentProject[];
projects: readonly SidebarProjectEntry[];
previewExpandedProjects?: ReadonlySet<string>;
collapsedProjectKeys?: ReadonlySet<string>;
limit?: number;
}): readonly SidebarSessionGroup[] {
const limit = input.limit ?? DEFAULT_GROUPED_SESSION_LIMIT;
const previewExpandedProjects = input.previewExpandedProjects ?? EMPTY_KEY_SET;
const collapsedProjectKeys = input.collapsedProjectKeys ?? EMPTY_KEY_SET;
const idsByProject = new Map<string, string[]>();
for (const agent of input.agentsWithProjects) {
let ids = idsByProject.get(agent.projectKey);
if (!ids) {
ids = [];
idsByProject.set(agent.projectKey, ids);
}
ids.push(agent.id);
}
const groups: SidebarSessionGroup[] = [];
for (const project of input.projects) {
const ids = idsByProject.get(project.projectKey);
if (!ids || ids.length === 0) {
continue;
}
const isCollapsed = collapsedProjectKeys.has(project.projectKey);
const isExpanded = !isCollapsed && previewExpandedProjects.has(project.projectKey);
groups.push({
projectKey: project.projectKey,
projectName: project.projectName,
projectIconKey: project.iconWorkingDir || null,
visibleIds: visibleIdsFor({ ids, isCollapsed, isExpanded, limit }),
hiddenCount: hiddenCountFor({ totalCount: ids.length, isCollapsed, isExpanded, limit }),
isExpanded,
isCollapsed,
totalCount: ids.length,
});
}
return groups;
}
function visibleIdsFor(input: {
ids: readonly string[];
isCollapsed: boolean;
isExpanded: boolean;
limit: number;
}): readonly string[] {
if (input.isCollapsed) {
return [];
}
if (input.isExpanded) {
return input.ids;
}
return input.ids.slice(0, input.limit);
}
function hiddenCountFor(input: {
totalCount: number;
isCollapsed: boolean;
isExpanded: boolean;
limit: number;
}): number {
if (input.isCollapsed || input.isExpanded) {
return 0;
}
return Math.max(0, input.totalCount - input.limit);
}
const EMPTY_KEY_SET: ReadonlySet<string> = new Set();
@@ -0,0 +1,141 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { Pressable, Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
const { archiveAgentMock, platformState } = vi.hoisted(() => ({
archiveAgentMock: vi.fn(),
platformState: { isNative: false },
}));
vi.mock("@/hooks/use-archive-agent", () => ({
useArchiveAgent: () => ({
archiveAgent: archiveAgentMock,
isArchivingAgent: () => false,
}),
}));
vi.mock("@/constants/platform", () => ({
get isNative() {
return platformState.isNative;
},
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
DropdownMenuItem: ({
children,
onSelect,
testID,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
}) => (
<Pressable testID={testID} onPress={onSelect}>
<Text>{children}</Text>
</Pressable>
),
DropdownMenuTrigger: ({
children,
testID,
}: {
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
testID?: string;
}) => (
<Pressable testID={testID}>
{typeof children === "function" ? children({ hovered: false }) : children}
</Pressable>
),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) =>
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
return React.createElement("span", { ...props, "data-icon": name });
};
return {
Archive: createIcon("Archive"),
MoreVertical: createIcon("MoreVertical"),
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) =>
typeof styles === "function"
? styles({
colors: {
foreground: "#111111",
foregroundMuted: "#666666",
surface2: "#eeeeee",
},
fontSize: { xs: 12 },
})
: styles,
},
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
function ThemedComponent(props: Record<string, unknown>) {
return <Component {...props} />;
},
}));
const SERVER_ID = "server-1";
const AGENT_ID = "agent-1";
beforeEach(() => {
archiveAgentMock.mockReset();
archiveAgentMock.mockResolvedValue(undefined);
platformState.isNative = false;
});
afterEach(() => {
cleanup();
});
describe("SidebarSessionRowKebabMenu", () => {
it("renders when hovered and hides on non-touch idle rows", () => {
const hidden = render(
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
);
expect(hidden.queryByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeNull();
hidden.unmount();
const visible = render(
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
);
expect(visible.getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
});
it("renders on touch platforms without hover", () => {
platformState.isNative = true;
const { getByTestId } = render(
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
);
expect(getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
});
it("archives the agent from the archive item", () => {
const { getAllByTestId, getByTestId, queryByTestId } = render(
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
);
expect(getAllByTestId(/^sidebar-session-menu-server-1-agent-1-/)).toHaveLength(1);
expect(queryByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`)).toBeNull();
fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-archive`));
expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID });
});
});
@@ -0,0 +1,93 @@
import React, { memo, useCallback, type ReactElement } from "react";
import { type PressableStateCallbackType } from "react-native";
import { Archive, MoreVertical } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { isNative as isTouchPlatform } from "@/constants/platform";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import type { Theme } from "@/styles/theme";
export interface SidebarSessionRowKebabMenuProps {
serverId: string;
agentId: string;
isHovered: boolean;
}
const ThemedArchive = withUnistyles(Archive);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function kebabButtonStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMenu({
serverId,
agentId,
isHovered,
}: SidebarSessionRowKebabMenuProps): ReactElement | null {
const visible = isHovered || isTouchPlatform;
const { archiveAgent } = useArchiveAgent();
const handleArchiveAgent = useCallback(() => {
void archiveAgent({ serverId, agentId }).catch(() => {});
}, [agentId, archiveAgent, serverId]);
if (!visible) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabButtonStyle}
accessibilityRole="button"
accessibilityLabel="Session actions"
testID={`sidebar-session-kebab-${serverId}-${agentId}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
testID={`sidebar-session-menu-${serverId}-${agentId}-archive`}
leading={archiveLeadingIcon}
onSelect={handleArchiveAgent}
>
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
});
const styles = StyleSheet.create((theme) => ({
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -0,0 +1,343 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, fireEvent, render } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { SidebarSessionsView } from "./view";
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
const {
aggregatedAgentsState,
navigateToPreparedWorkspaceTabMock,
prepareWorkspaceTabMock,
testTheme,
} = vi.hoisted(() => ({
aggregatedAgentsState: {
value: {
agents: [] as unknown[],
isInitialLoad: false,
},
},
navigateToPreparedWorkspaceTabMock: vi.fn(),
prepareWorkspaceTabMock: vi.fn(),
testTheme: {
borderRadius: { full: 999, lg: 8 },
colors: {
foreground: "#111111",
foregroundMuted: "#666666",
palette: {
amber: { 500: "#f59e0b", 700: "#b45309" },
blue: { 500: "#3b82f6" },
green: { 500: "#22c55e" },
red: { 500: "#ef4444" },
},
surface0: "#ffffff",
surface2: "#eeeeee",
surfaceSidebarHover: "#f5f5f5",
},
colorScheme: "light",
fontSize: { sm: 14, xs: 12 },
fontWeight: { medium: "500" },
iconSize: { md: 20, sm: 16 },
shadow: { md: {} },
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
},
}));
vi.mock("@/hooks/use-aggregated-agents", () => ({
useAggregatedAgentIds: (options?: { filter?: (agent: AggregatedAgent) => boolean }) =>
aggregatedAgentsState.value.agents
.filter((agent) => !options?.filter || options.filter(agent as AggregatedAgent))
.map((agent) => (agent as AggregatedAgent).id),
useAggregatedAgentsInitialLoad: () => aggregatedAgentsState.value.isInitialLoad,
}));
vi.mock("@/components/provider-icons", async () => {
const ReactModule = await import("react");
return {
getProviderIcon: () =>
function ProviderIcon() {
return ReactModule.createElement("span", { "data-testid": "provider-icon" });
},
};
});
vi.mock("./session-row-actions", () => ({
SidebarSessionRowKebabMenu: () => null,
}));
vi.mock("@/components/sidebar/sidebar-project-trailing-actions", () => ({
SidebarProjectTrailingActions: () => null,
}));
vi.mock("expo-router", () => ({
router: {
navigate: vi.fn(),
},
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) =>
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
return React.createElement("span", { ...props, "data-icon": name });
};
return {
Archive: createIcon("Archive"),
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
FolderPlus: createIcon("FolderPlus"),
MoreVertical: createIcon("MoreVertical"),
Settings: createIcon("Settings"),
Trash2: createIcon("Trash2"),
X: createIcon("X"),
};
});
vi.mock("@/screens/workspace/workspace-tab-presentation", async () => {
const ReactModule = await import("react");
return {
WorkspaceTabPresentationResolver: ({ children }: { children: (presentation: {}) => unknown }) =>
children({}),
WorkspaceTabIcon: () =>
ReactModule.createElement("span", { "data-testid": "workspace-tab-icon" }),
};
});
vi.mock("react-native-draggable-flatlist", async () => {
const ReactModule = await import("react");
return {
NestableScrollContainer: ({ children }: { children?: React.ReactNode }) =>
ReactModule.createElement("div", null, children),
default: () => null,
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Easing: {
linear: vi.fn(),
},
makeMutable: (value: unknown) => ({ value }),
useAnimatedStyle: (factory: () => unknown) => factory(),
withRepeat: (value: unknown) => value,
withTiming: (value: unknown) => value,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: (theme: unknown) => unknown) => styles(testTheme),
},
withUnistyles: (Component: unknown) => Component,
useUnistyles: () => ({
theme: testTheme,
rt: {},
breakpoint: undefined,
}),
}));
vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: navigateToPreparedWorkspaceTabMock,
prepareWorkspaceTab: prepareWorkspaceTabMock,
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
const SERVER_ID = "server-1";
const WORKSPACE_ID = "workspace-1";
const AGENT_ID = "agent-1";
const CWD = "/repo/project/workspace";
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const ALL_SESSIONS_FILTER = { type: "all" } as const;
const AGENT_DEFAULTS: Agent = {
serverId: SERVER_ID,
id: AGENT_ID,
provider: "codex",
status: "running",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Navigation agent",
cwd: CWD,
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input: Partial<Agent> = {}): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function toAggregatedAgent(agent: Agent): AggregatedAgent {
return {
...agent,
serverLabel: "Server 1",
};
}
function workspace(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
return {
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
workspaceDirectory: CWD,
projectKind: "git",
workspaceKind: "worktree",
name: "workspace",
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
...input,
};
}
function projects(): SidebarProjectEntry[] {
return [
{
projectKey: "project-1",
projectName: "Project",
projectKind: "git",
iconWorkingDir: "/repo/project",
workspaces: [
{
workspaceKey: `${SERVER_ID}:${WORKSPACE_ID}`,
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
projectKey: "project-1",
projectRootPath: "/repo/project",
workspaceDirectory: CWD,
projectKind: "git",
workspaceKind: "worktree",
name: "workspace",
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
},
],
},
];
}
function seedState(input: { agent: Agent; workspaces?: WorkspaceDescriptor[] }) {
const agent = input.agent;
aggregatedAgentsState.value = {
agents: [toAggregatedAgent(agent)],
isInitialLoad: false,
};
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
useSessionStore
.getState()
.setWorkspaces(
SERVER_ID,
new Map((input.workspaces ?? [workspace()]).map((entry) => [entry.id, entry])),
);
}
beforeEach(() => {
prepareWorkspaceTabMock.mockReset();
navigateToPreparedWorkspaceTabMock.mockReset();
});
afterEach(() => {
cleanup();
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("SidebarSessionsView row navigation", () => {
it("prepares and navigates to the agent tab in the resolved workspace", () => {
seedState({ agent: makeAgent() });
const { getByTestId } = render(
<SidebarSessionsView
serverId={SERVER_ID}
projects={projects()}
filter={ALL_SESSIONS_FILTER}
groupByProject={false}
previewExpandedProjects={new Set()}
collapsedProjectKeys={new Set()}
onProjectPreviewExpandedToggle={vi.fn()}
onProjectCollapsedToggle={vi.fn()}
/>,
);
fireEvent.click(getByTestId(`sidebar-session-row-${SERVER_ID}-${AGENT_ID}`));
const navigationInput = {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
};
expect(prepareWorkspaceTabMock).toHaveBeenCalledTimes(1);
expect(prepareWorkspaceTabMock).toHaveBeenCalledWith(navigationInput);
expect(navigateToPreparedWorkspaceTabMock).toHaveBeenCalledTimes(1);
expect(navigateToPreparedWorkspaceTabMock).toHaveBeenCalledWith(navigationInput);
expect(prepareWorkspaceTabMock.mock.invocationCallOrder[0]).toBeLessThan(
navigateToPreparedWorkspaceTabMock.mock.invocationCallOrder[0] ?? 0,
);
});
it("does nothing when the row agent no longer resolves to a workspace", () => {
seedState({ agent: makeAgent() });
const { getByTestId } = render(
<SidebarSessionsView
serverId={SERVER_ID}
projects={projects()}
filter={ALL_SESSIONS_FILTER}
groupByProject={false}
previewExpandedProjects={new Set()}
collapsedProjectKeys={new Set()}
onProjectPreviewExpandedToggle={vi.fn()}
onProjectCollapsedToggle={vi.fn()}
/>,
);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
fireEvent.click(getByTestId(`sidebar-session-row-${SERVER_ID}-${AGENT_ID}`));
expect(prepareWorkspaceTabMock).not.toHaveBeenCalled();
expect(navigateToPreparedWorkspaceTabMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,427 @@
import {
Folder,
Folders,
LayoutList,
ListFilter,
MessagesSquare,
Plus,
type LucideIcon,
} from "lucide-react-native";
import { memo, useCallback, useMemo, type ReactElement } from "react";
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces";
interface SidebarSessionsToggleProps {
serverId: string | null;
mode: SidebarSessionViewMode;
filter: SidebarSessionFilter;
projects: readonly SidebarProjectEntry[];
groupByProject: boolean;
onModeChange: (mode: SidebarSessionViewMode) => void;
onFilterChange: (filter: SidebarSessionFilter) => void;
onGroupByProjectChange: (next: boolean) => void;
}
const PILL_TRANSITION = LinearTransition.duration(160);
const LABEL_FADE = { in: FadeIn.duration(120), out: FadeOut.duration(80) };
export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({
serverId,
mode,
filter,
projects,
groupByProject,
onModeChange,
onFilterChange,
onGroupByProjectChange,
}: SidebarSessionsToggleProps): ReactElement {
const sessionsActive = mode === "sessions";
const handleWorkspacesPress = useCallback(() => {
onModeChange("workspaces");
}, [onModeChange]);
const handleSessionsPress = useCallback(() => {
onModeChange("sessions");
}, [onModeChange]);
return (
<View style={styles.container}>
<View style={styles.pillGroup}>
<TogglePill
label="Workspaces"
icon={Folders}
active={!sessionsActive}
onPress={handleWorkspacesPress}
testID="sidebar-toggle-workspaces"
/>
<TogglePill
label="Sessions"
icon={MessagesSquare}
active={sessionsActive}
onPress={handleSessionsPress}
testID="sidebar-toggle-sessions"
/>
</View>
{sessionsActive ? (
<SidebarSessionsFilterMenu
serverId={serverId}
filter={filter}
projects={projects}
groupByProject={groupByProject}
onFilterChange={onFilterChange}
onGroupByProjectChange={onGroupByProjectChange}
/>
) : null}
</View>
);
});
function SidebarSessionsFilterMenu({
serverId,
filter,
projects,
groupByProject,
onFilterChange,
onGroupByProjectChange,
}: {
serverId: string | null;
filter: SidebarSessionFilter;
projects: readonly SidebarProjectEntry[];
groupByProject: boolean;
onFilterChange: (filter: SidebarSessionFilter) => void;
onGroupByProjectChange: (next: boolean) => void;
}) {
const { theme } = useUnistyles();
const filterActive = filter.type !== "all";
const visibleFilterProjects = useVisibleSidebarSessionFilterProjects({ serverId, projects });
const newSessionIntent = useMemo(
() => ({ kind: "new-workspace", headerTitle: "New session" }) as const,
[],
);
const openNewSessionPicker = useOpenProjectPicker(serverId, newSessionIntent);
const handleAllSelect = useCallback(() => {
onFilterChange({ type: "all" });
}, [onFilterChange]);
const handleNewSessionPress = useCallback(() => {
void openNewSessionPicker();
}, [openNewSessionPicker]);
const newSessionButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterButton,
(hovered || pressed) && styles.filterButtonHovered,
],
[],
);
const filterTriggerStyle = useCallback(
({ hovered = false, open = false }: PressableStateCallbackType & { open?: boolean }) => [
styles.filterButton,
(hovered || open || filterActive) && styles.filterButtonHovered,
],
[filterActive],
);
const filterIcon = useMemo(
() => (
<ListFilter
size={theme.iconSize.sm}
color={filterActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
),
[filterActive, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
);
const handleGroupByProjectPress = useCallback(() => {
onGroupByProjectChange(!groupByProject);
}, [groupByProject, onGroupByProjectChange]);
const groupByProjectStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterButton,
(hovered || groupByProject) && styles.filterButtonHovered,
],
[groupByProject],
);
const groupByProjectIcon = useMemo(
() => (
<LayoutList
size={theme.iconSize.sm}
color={groupByProject ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
),
[groupByProject, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
);
return (
<View style={styles.sessionActions}>
<Pressable
accessibilityLabel="New session"
accessibilityRole="button"
onPress={handleNewSessionPress}
style={newSessionButtonStyle}
testID="sidebar-new-session-button"
>
{({ hovered, pressed }) => (
<Plus
size={theme.iconSize.sm}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
accessibilityLabel="Filter sessions"
accessibilityRole="button"
style={filterTriggerStyle}
testID="sidebar-sessions-filter-trigger"
>
{filterIcon}
{filterActive ? <View style={styles.filterBadge} /> : null}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} scrollable maxHeight={420}>
<DropdownMenuItem selected={filter.type === "all"} onSelect={handleAllSelect}>
All
</DropdownMenuItem>
{visibleFilterProjects.length > 0 ? (
<>
<DropdownMenuSeparator />
<FilterSectionHeading>Project</FilterSectionHeading>
{visibleFilterProjects.map((project) => (
<ProjectFilterItem
key={project.projectKey}
selected={filter.type === "project" && filter.projectKey === project.projectKey}
label={project.projectName}
projectKey={project.projectKey}
serverId={serverId}
iconWorkingDir={project.iconWorkingDir}
onFilterChange={onFilterChange}
/>
))}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Pressable
accessibilityLabel="Group by project"
accessibilityRole="button"
onPress={handleGroupByProjectPress}
style={groupByProjectStyle}
testID="sidebar-toggle-group-by-project"
>
{groupByProjectIcon}
</Pressable>
</View>
);
}
function TogglePill({
label,
icon: Icon,
active,
onPress,
testID,
}: {
label: string;
icon: LucideIcon;
active: boolean;
onPress: () => void;
testID?: string;
}) {
const { theme } = useUnistyles();
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
const pillStyleActive = useMemo(() => [styles.pill, styles.pillActive], []);
const pillStyleHovered = useMemo(() => [styles.pill, styles.pillHovered], []);
const renderInner = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
let pillStyle;
if (active) pillStyle = pillStyleActive;
else if (hovered) pillStyle = pillStyleHovered;
else pillStyle = styles.pill;
return (
<Animated.View layout={PILL_TRANSITION} style={pillStyle}>
<Icon size={theme.iconSize.sm} color={iconColor} />
{active ? (
<Animated.Text
entering={LABEL_FADE.in}
exiting={LABEL_FADE.out}
style={styles.pillTextActive}
>
{label}
</Animated.Text>
) : null}
</Animated.View>
);
},
[Icon, active, iconColor, label, pillStyleActive, pillStyleHovered, theme.iconSize.sm],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
testID={testID}
>
{renderInner}
</Pressable>
);
}
function FilterSectionHeading({ children }: { children: string }) {
return (
<View style={styles.filterSectionHeading}>
<Text style={styles.filterSectionHeadingText}>{children}</Text>
</View>
);
}
function ProjectFilterItem({
selected,
label,
projectKey,
serverId,
iconWorkingDir,
onFilterChange,
}: {
selected: boolean;
label: string;
projectKey: string;
serverId: string | null;
iconWorkingDir: string;
onFilterChange: (filter: SidebarSessionFilter) => void;
}) {
const handleSelect = useCallback(() => {
onFilterChange({ type: "project", projectKey });
}, [onFilterChange, projectKey]);
const leading = useMemo(
() => <ProjectIconLeading serverId={serverId} cwd={iconWorkingDir} />,
[serverId, iconWorkingDir],
);
return (
<DropdownMenuItem selected={selected} leading={leading} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
function ProjectIconLeading({ serverId, cwd }: { serverId: string | null; cwd: string }) {
const { theme } = useUnistyles();
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd });
const dataUri = useMemo(() => {
if (!icon || !icon.mimeType || !icon.data) {
return null;
}
return `data:${icon.mimeType};base64,${icon.data}`;
}, [icon]);
const imageSource = useMemo(() => (dataUri ? { uri: dataUri } : null), [dataUri]);
return (
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
{imageSource ? (
<Image source={imageSource} style={styles.projectIconImage} />
) : (
<View style={styles.projectIconFallback}>
<Folder size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
)}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[2],
},
pillGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
},
sessionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
pill: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 28,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
pillActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
paddingRight: theme.spacing[3],
},
pillHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
pillTextActive: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
filterButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
position: "relative",
},
filterButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
filterBadge: {
position: "absolute",
top: 5,
right: 5,
width: 5,
height: 5,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foreground,
},
filterSectionHeading: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
filterSectionHeadingText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
projectIconImage: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -0,0 +1,23 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
export type SidebarSessionViewMode = "workspaces" | "sessions";
export type SidebarSessionFilter = { type: "all" } | { type: "project"; projectKey: string };
export interface SidebarSessionWorkspace {
serverId: string;
workspaceId: string;
workspaceKey: string;
workspaceName: string;
projectKey: string;
projectName: string;
projectIconKey: string | null;
workspaceDirectory: string;
}
export type ResolvedSidebarSessionProject = Pick<
SidebarSessionWorkspace,
"projectKey" | "projectName" | "projectIconKey"
>;
export type SidebarSessionAgent = Pick<AggregatedAgent, "id" | "serverId" | "cwd" | "archivedAt">;
@@ -0,0 +1,116 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const WORKSPACE: WorkspaceDescriptor = {
id: "workspace-1",
projectId: "project-a",
projectDisplayName: "Project A",
projectRootPath: "/abs/path/to/repo",
workspaceDirectory: "/abs/path/to/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feat-foo",
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
};
const AGENT: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "idle",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/abs/path/to/repo",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
const PROJECTS: SidebarProjectEntry[] = [
{
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/abs/path/to/repo",
workspaces: [
{
workspaceKey: "server-1:workspace-1",
serverId: "server-1",
workspaceId: "workspace-1",
projectKey: "project-a",
projectRootPath: "/abs/path/to/repo",
workspaceDirectory: "/abs/path/to/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "/abs/path/to/repo",
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
},
],
},
];
afterEach(() => {
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("useVisibleSidebarSessionFilterProjects", () => {
it("returns projects with mapped active sessions", () => {
act(() => {
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces("server-1", new Map([["workspace-1", WORKSPACE]]));
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", AGENT]]));
});
const { result } = renderHook(() =>
useVisibleSidebarSessionFilterProjects({
serverId: "server-1",
projects: PROJECTS,
}),
);
expect(result.current.map((project) => project.projectKey)).toEqual(["project-a"]);
});
});
@@ -0,0 +1,115 @@
import equal from "fast-deep-equal";
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore } from "@/stores/session-store";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSessionWorkspace } from "./types";
import {
createSidebarSessionWorkspaceLookup,
deriveSidebarSessionFilterAvailability,
deriveSidebarSessionFilterProjects,
} from "./session-filtering";
const EMPTY_WORKSPACES: SidebarSessionWorkspace[] = [];
const EMPTY_AVAILABILITY = { projectKeys: [] } as const;
export function useSidebarSessionWorkspaces(input: {
serverId: string | null;
projects: readonly SidebarProjectEntry[];
}): SidebarSessionWorkspace[] {
const workspaceFieldsById = useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!input.serverId) {
return {};
}
const workspaces = state.sessions[input.serverId]?.workspaces;
if (!workspaces || workspaces.size === 0) {
return {};
}
const fieldsById: Record<string, { name: string; workspaceDirectory: string | undefined }> =
{};
for (const workspace of workspaces.values()) {
const entry = createSidebarWorkspaceEntry({ serverId: input.serverId, workspace });
fieldsById[entry.workspaceId] = {
name: entry.name,
workspaceDirectory: entry.workspaceDirectory,
};
}
return fieldsById;
},
equal,
);
return useMemo(() => {
if (!input.serverId || input.projects.length === 0) {
return EMPTY_WORKSPACES;
}
const result: SidebarSessionWorkspace[] = [];
for (const project of input.projects) {
for (const workspace of project.workspaces) {
const workspaceFields = workspaceFieldsById[workspace.workspaceId];
const workspaceDirectory = workspaceFields?.workspaceDirectory;
if (!workspaceDirectory) {
continue;
}
result.push({
serverId: input.serverId,
workspaceId: workspace.workspaceId,
workspaceKey: workspace.workspaceKey,
workspaceName: workspaceFields.name,
projectKey: project.projectKey,
projectName: project.projectName,
projectIconKey: project.iconWorkingDir || null,
workspaceDirectory,
});
}
}
return result;
}, [input.projects, input.serverId, workspaceFieldsById]);
}
export function useVisibleSidebarSessionFilterProjects(input: {
serverId: string | null;
projects: readonly SidebarProjectEntry[];
}): SidebarProjectEntry[] {
const workspaces = useSidebarSessionWorkspaces(input);
const lookup = useMemo(() => createSidebarSessionWorkspaceLookup(workspaces), [workspaces]);
const availability = useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!input.serverId || lookup.workspaceByExecutionKey.size === 0) {
return EMPTY_AVAILABILITY;
}
const serverId = input.serverId;
const agents = state.sessions[serverId]?.agents;
if (!agents || agents.size === 0) {
return EMPTY_AVAILABILITY;
}
return deriveSidebarSessionFilterAvailability({
agents: Array.from(agents.values(), (agent) => ({
id: agent.id,
serverId,
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
})),
lookup,
});
},
equal,
);
return useMemo(
() =>
deriveSidebarSessionFilterProjects({
projects: input.projects,
availability,
}),
[availability, input.projects],
);
}
+511
View File
@@ -0,0 +1,511 @@
import React, { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import {
FlatList,
Pressable,
ScrollView,
Text,
View,
type ListRenderItem,
type PressableStateCallbackType,
} from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { useShallow } from "zustand/shallow";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { isNative as platformIsNative } from "@/constants/platform";
import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton";
import { sidebarProjectChildIndentStyles } from "@/components/sidebar/sidebar-collapsible-project-section";
import {
SidebarProjectsDraggableList,
type SidebarProjectDragInfo,
} from "@/components/sidebar/sidebar-projects-draggable-list";
import {
type AggregatedAgentIdEntry,
useAggregatedAgentIds,
useAggregatedAgentsInitialLoad,
} from "@/hooks/use-aggregated-agents";
import {
WorkspaceTabIcon,
WorkspaceTabPresentationResolver,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore } from "@/stores/session-store";
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { formatTimeAgo } from "@/utils/time";
import {
createSidebarSessionWorkspaceLookup,
resolveSidebarSessionWorkspaceId,
resolveSidebarSessionWorkspace,
shouldIncludeSidebarSessionAgent,
type SidebarSessionGroup,
} from "./session-filtering";
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
import type { ResolvedSidebarSessionProject, SidebarSessionFilter } from "./types";
import { useSidebarSessionWorkspaces } from "./use-sidebar-session-workspaces";
import {
GROUPED_SESSION_LIMIT,
SidebarSessionGroupFooter,
SidebarSessionGroupHeader,
useGroupedSidebarSessionGroups,
useOrderedAgentProjectShape,
} from "./grouped-view";
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
interface SidebarSessionsViewProps {
serverId: string | null;
projects: readonly SidebarProjectEntry[];
filter: SidebarSessionFilter;
groupByProject: boolean;
previewExpandedProjects: ReadonlySet<string>;
collapsedProjectKeys: ReadonlySet<string>;
onProjectPreviewExpandedToggle: (projectKey: string) => void;
onProjectCollapsedToggle: (projectKey: string) => void;
}
export function SidebarSessionsView({
serverId,
projects,
filter,
groupByProject,
previewExpandedProjects,
collapsedProjectKeys,
onProjectPreviewExpandedToggle,
onProjectCollapsedToggle,
}: SidebarSessionsViewProps): ReactElement {
const isInitialLoad = useAggregatedAgentsInitialLoad();
const workspaces = useSidebarSessionWorkspaces({ serverId, projects });
const lookup = useMemo(() => createSidebarSessionWorkspaceLookup(workspaces), [workspaces]);
const filterAgent = useCallback(
(agent: AggregatedAgentIdEntry) =>
shouldIncludeSidebarSessionAgent({
agent,
filter,
lookup,
}),
[filter, lookup],
);
const sessionIds = useAggregatedAgentIds({
filter: filterAgent,
sort: "createdAt-desc-stable",
});
const resolveCwdToProject = useCallback(
(cwd: string) => {
if (!serverId) {
return null;
}
const workspace = resolveSidebarSessionWorkspace(lookup, {
id: "",
serverId,
cwd,
archivedAt: null,
});
if (!workspace) {
return null;
}
return {
projectKey: workspace.projectKey,
projectName: workspace.projectName,
projectIconKey: workspace.projectIconKey,
};
},
[lookup, serverId],
);
if (isInitialLoad) {
return <SidebarAgentListSkeleton />;
}
if (groupByProject) {
return (
<GroupedSidebarSessionsList
serverId={serverId}
sessionIds={sessionIds}
projects={projects}
resolveCwdToProject={resolveCwdToProject}
previewExpandedProjects={previewExpandedProjects}
collapsedProjectKeys={collapsedProjectKeys}
onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle}
onProjectCollapsedToggle={onProjectCollapsedToggle}
/>
);
}
return <FlatSidebarSessionsList serverId={serverId} sessionIds={sessionIds} />;
}
const FlatSidebarSessionsList = memo(function FlatSidebarSessionsList({
serverId,
sessionIds,
}: {
serverId: string | null;
sessionIds: readonly string[];
}): ReactElement {
const renderItem: ListRenderItem<string> = useCallback(
({ item }) => (serverId ? <SidebarSessionRow id={item} serverId={serverId} /> : null),
[serverId],
);
const keyExtractor = useCallback((id: string) => `${serverId ?? ""}:${id}`, [serverId]);
return (
<FlatList
data={sessionIds}
style={styles.list}
contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={EmptySessions}
/>
);
});
const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({
serverId,
sessionIds,
projects,
resolveCwdToProject,
previewExpandedProjects,
collapsedProjectKeys,
onProjectPreviewExpandedToggle,
onProjectCollapsedToggle,
}: {
serverId: string | null;
sessionIds: readonly string[];
projects: readonly SidebarProjectEntry[];
resolveCwdToProject: (cwd: string) => ResolvedSidebarSessionProject | null;
previewExpandedProjects: ReadonlySet<string>;
collapsedProjectKeys: ReadonlySet<string>;
onProjectPreviewExpandedToggle: (projectKey: string) => void;
onProjectCollapsedToggle: (projectKey: string) => void;
}): ReactElement {
const agentsWithProjects = useOrderedAgentProjectShape({
orderedIds: sessionIds,
serverId: serverId ?? "",
resolveCwdToProject,
});
const groups = useGroupedSidebarSessionGroups({
agentsWithProjects,
projects,
previewExpandedProjects,
collapsedProjectKeys,
});
const groupsByKey = useMemo(() => {
const map = new Map<string, SidebarSessionGroup>();
for (const group of groups) {
map.set(group.projectKey, group);
}
return map;
}, [groups]);
const projectsWithSessions = useMemo(
() => projects.filter((project) => groupsByKey.has(project.projectKey)),
[groupsByKey, projects],
);
const renderProject = useCallback(
({ project, drag, isDragging, dragHandleProps }: SidebarProjectDragInfo) => {
const group = groupsByKey.get(project.projectKey);
if (!group) {
return <View />;
}
return (
<SidebarSessionGroupSection
serverId={serverId}
project={project}
group={group}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
onProjectCollapsedToggle={onProjectCollapsedToggle}
onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle}
/>
);
},
[groupsByKey, onProjectCollapsedToggle, onProjectPreviewExpandedToggle, serverId],
);
if (projectsWithSessions.length === 0) {
return (
<View style={styles.list}>
<View style={styles.listContent}>
<EmptySessions />
</View>
</View>
);
}
const draggableList = (
<SidebarProjectsDraggableList
testID="sidebar-session-group-list"
projects={projectsWithSessions}
serverId={serverId}
renderProject={renderProject}
nestable={platformIsNative}
/>
);
return platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-session-group-scroll"
>
{draggableList}
</NestableScrollContainer>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-session-group-scroll"
>
{draggableList}
</ScrollView>
);
});
const SidebarSessionGroupSection = memo(function SidebarSessionGroupSection({
serverId,
project,
group,
drag,
isDragging,
dragHandleProps,
onProjectCollapsedToggle,
onProjectPreviewExpandedToggle,
}: {
serverId: string | null;
project: SidebarProjectEntry;
group: SidebarSessionGroup;
drag: () => void;
isDragging: boolean;
dragHandleProps?: SidebarProjectDragInfo["dragHandleProps"];
onProjectCollapsedToggle: (projectKey: string) => void;
onProjectPreviewExpandedToggle: (projectKey: string) => void;
}): ReactElement {
const showFooter = !group.isCollapsed && group.totalCount > GROUPED_SESSION_LIMIT;
const footer = useMemo(
() =>
showFooter ? (
<SidebarSessionGroupFooter
projectKey={group.projectKey}
hiddenCount={group.hiddenCount}
isExpanded={group.isExpanded}
onPress={onProjectPreviewExpandedToggle}
/>
) : null,
[
group.hiddenCount,
group.isExpanded,
group.projectKey,
onProjectPreviewExpandedToggle,
showFooter,
],
);
return (
<View>
<SidebarSessionGroupHeader
serverId={serverId}
projectKey={group.projectKey}
projectName={group.projectName}
projectIconKey={group.projectIconKey}
workspaces={project.workspaces}
isCollapsed={group.isCollapsed}
isDragging={isDragging}
drag={drag}
dragHandleProps={dragHandleProps}
onToggleCollapsed={onProjectCollapsedToggle}
footer={footer}
>
{serverId
? group.visibleIds.map((id) => (
<SidebarSessionRow key={id} id={id} serverId={serverId} indented />
))
: null}
</SidebarSessionGroupHeader>
</View>
);
});
const SidebarSessionRow = memo(function SidebarSessionRow({
id,
serverId,
indented = false,
}: {
id: string;
serverId: string;
indented?: boolean;
}): ReactElement | null {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const agent = useSessionStore(
useShallow((state) => selectSidebarSessionSlice(state, serverId, id)),
);
const agentCwd = agent?.cwd ?? null;
const handlePress = useCallback(() => {
if (!agentCwd) {
return;
}
const workspaceId = resolveSidebarSessionWorkspaceId({
agent: {
id,
serverId,
cwd: agentCwd,
archivedAt: null,
},
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
});
if (!workspaceId) {
return;
}
const target = { kind: "agent" as const, agentId: id };
prepareWorkspaceTab({
serverId,
workspaceId,
target,
});
navigateToPreparedWorkspaceTab({
serverId,
workspaceId,
target,
});
}, [agentCwd, id, serverId]);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.row,
indented && sidebarProjectChildIndentStyles.childRow,
isHovered && styles.rowHovered,
pressed && styles.rowPressed,
],
[indented, isHovered],
);
const titleStyle = useMemo(() => [styles.title, isHovered && styles.titleHovered], [isHovered]);
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: `agent_${id}`,
tabId: `agent_${id}`,
kind: "agent",
target: { kind: "agent", agentId: id },
}),
[id],
);
if (!agent || agent.archivedAt) {
return null;
}
return (
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable
accessibilityRole="button"
accessibilityLabel={agent.title || "New session"}
onPress={handlePress}
style={pressableStyle}
testID={`sidebar-session-row-${serverId}-${id}`}
>
<View style={styles.providerIconWrap}>
<WorkspaceTabPresentationResolver
tab={tabDescriptor}
serverId={serverId}
workspaceId="sidebar-sessions"
>
{(presentation) => (
<WorkspaceTabIcon
presentation={presentation}
active={isHovered}
size={theme.iconSize.sm}
statusDotBorderColor={isHovered ? theme.colors.surfaceSidebarHover : undefined}
/>
)}
</WorkspaceTabPresentationResolver>
</View>
<Text style={titleStyle} numberOfLines={1}>
{agent.title || "New session"}
</Text>
<Text style={styles.timeAgo} numberOfLines={1}>
{formatTimeAgo(agent.lastActivityAt)}
</Text>
<SidebarSessionRowKebabMenu serverId={serverId} agentId={id} isHovered={isHovered} />
</Pressable>
</View>
);
});
function EmptySessions(): ReactElement {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No sessions</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
list: {
flex: 1,
minHeight: 0,
},
listContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
row: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
},
rowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
rowPressed: {
backgroundColor: theme.colors.surface2,
},
providerIconWrap: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
title: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
color: theme.colors.foreground,
opacity: 0.76,
},
titleHovered: {
opacity: 1,
},
timeAgo: {
flexShrink: 0,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
emptyState: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[6],
},
emptyText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}));
@@ -5,6 +5,7 @@ beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
projectPickerOpen: false,
projectPickerIntent: { kind: "open-project" },
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -25,4 +26,23 @@ describe("keyboard-shortcuts-store", () => {
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
});
it("stores and resets the project picker intent", () => {
useKeyboardShortcutsStore
.getState()
.setProjectPickerOpen(true, { kind: "new-workspace", headerTitle: "New session" });
expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(true);
expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
kind: "new-workspace",
headerTitle: "New session",
});
useKeyboardShortcutsStore.getState().setProjectPickerOpen(false);
expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(false);
expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
kind: "open-project",
});
});
});
@@ -3,9 +3,14 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
const SHORTCUT_BADGE_DELAY_MS = 150;
export type ProjectPickerIntent =
| { kind: "open-project" }
| { kind: "new-workspace"; headerTitle: string };
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
projectPickerOpen: boolean;
projectPickerIntent: ProjectPickerIntent;
shortcutsDialogOpen: boolean;
capturingShortcut: boolean;
altDown: boolean;
@@ -15,7 +20,7 @@ interface KeyboardShortcutsState {
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
setCommandCenterOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean, intent?: ProjectPickerIntent) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setCapturingShortcut: (capturing: boolean) => void;
setAltDown: (down: boolean) => void;
@@ -50,6 +55,7 @@ function updateBadgeTimer(
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
commandCenterOpen: false,
projectPickerOpen: false,
projectPickerIntent: { kind: "open-project" },
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -58,7 +64,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
sidebarShortcutWorkspaceTargets: [],
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
setProjectPickerOpen: (open, intent) =>
set({
projectPickerOpen: open,
projectPickerIntent: open ? (intent ?? { kind: "open-project" }) : { kind: "open-project" },
}),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
setAltDown: (down) => {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildHostAgentDetailRoute,
buildHostNewWorkspaceRoute,
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
@@ -160,3 +161,14 @@ describe("projects settings routes", () => {
expect(decodeURIComponent(segment)).toBe(projectKey);
});
});
describe("new workspace route", () => {
it("can carry a header title separately from the project display name", () => {
expect(
buildHostNewWorkspaceRoute("server-1", "/repo", {
displayName: "Project",
headerTitle: "New session",
}),
).toBe("/h/server-1/new?dir=%2Frepo&name=Project&title=New+session");
});
});
+4 -1
View File
@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
export function buildHostNewWorkspaceRoute(
serverId: string,
sourceDirectory: string,
options?: { displayName?: string },
options?: { displayName?: string; headerTitle?: string },
) {
const base = buildHostRootRoute(serverId);
if (base === "/") {
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
if (options?.displayName) {
params.set("name", options.displayName);
}
if (options?.headerTitle) {
params.set("title", options.headerTitle);
}
return `${base}/new?${params.toString()}` as const;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.69",
"@getpaseo/server": "0.1.70-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+5
View File
@@ -60,6 +60,11 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.6 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-sonnet-4-6[1m]",
model: "Sonnet 4.6 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-sonnet-4-6",
model: "Sonnet 4.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+15 -3
View File
@@ -53,10 +53,22 @@ function pruneOnnxRuntime(nodeModules, platform, arch) {
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
if (!keepName) return;
if (keepName) {
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
}
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
// SDK ≥0.2.113 ships per-platform Claude Code binaries via optionalDependencies
// (~210 MB each). Paseo requires user-installed `claude` on PATH, matching how
// Codex/OpenCode are integrated, so drop every bundled copy.
const anthropicDir = path.join(nodeModules, "@anthropic-ai");
if (fs.existsSync(anthropicDir)) {
for (const entry of fs.readdirSync(anthropicDir)) {
if (entry.startsWith("claude-agent-sdk-")) {
rmSafe(path.join(anthropicDir, entry));
}
}
}
}
function pruneNodePty(nodeModules, platform, arch) {
@@ -17,12 +17,13 @@ import {
downloadAndInstallUpdate,
type AppReleaseChannel,
} from "../features/auto-updater.js";
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
import {
installCli,
getCliInstallStatus,
getSkillsStatus,
installSkills,
getSkillsInstallStatus,
} from "../integrations/integrations-manager.js";
uninstallSkills,
updateSkills,
} from "../integrations/skills/index.js";
import {
openLocalTransportSession,
sendLocalTransportMessage,
@@ -531,8 +532,10 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(),
get_skills_status: () => getSkillsStatus(),
install_skills: () => installSkills(),
get_skills_install_status: () => getSkillsInstallStatus(),
update_skills: () => updateSkills(),
uninstall_skills: () => uninstallSkills(),
};
}
+5 -12
View File
@@ -196,16 +196,9 @@ function buildCheckResult(input: {
};
}
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
// Use a short delay to allow the renderer to receive the response.
setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
}
// ---------------------------------------------------------------------------
@@ -314,7 +307,7 @@ export async function downloadAndInstallUpdate(
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {
scheduleQuitAndInstall(onBeforeQuit);
await performQuitAndInstall(onBeforeQuit);
return {
installed: true,
version: readyVersion,
@@ -336,7 +329,7 @@ export async function downloadAndInstallUpdate(
await autoUpdater.downloadUpdate();
downloadedUpdateVersion = readyVersion;
downloading = false;
scheduleQuitAndInstall(onBeforeQuit);
await performQuitAndInstall(onBeforeQuit);
return {
installed: true,
@@ -0,0 +1 @@
export { getCliInstallStatus, installCli } from "./install.js";
@@ -0,0 +1,71 @@
import { promises as fs } from "node:fs";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./path.js";
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
import { ensurePathInShellRc } from "./shell-rc.js";
interface InstallStatus {
installed: boolean;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./cli-install-path";
import { resolveCliInstallSourcePath } from "./path";
describe("cli-install-path", () => {
it("uses the bundled shim for packaged macOS installs", () => {
@@ -0,0 +1,31 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
export function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
export function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
@@ -0,0 +1,97 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import log from "electron-log/main";
export interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
export function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
@@ -1,318 +0,0 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
import { syncSkills } from "./skill-sync.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InstallStatus {
installed: boolean;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
];
// ---------------------------------------------------------------------------
// Filesystem helpers
// ---------------------------------------------------------------------------
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "skills");
}
function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
// ---------------------------------------------------------------------------
// Shell PATH helpers
// ---------------------------------------------------------------------------
interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
// ---------------------------------------------------------------------------
// CLI Installation
// ---------------------------------------------------------------------------
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
// ---------------------------------------------------------------------------
// Skills Installation
// ---------------------------------------------------------------------------
function getSkillSyncTargets(): {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
} {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
export async function installSkills(): Promise<InstallStatus> {
const targets = getSkillSyncTargets();
log.info("[integrations] installSkills", targets);
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill install failed", { skillName, error });
},
});
log.info("[integrations] installSkills done", result);
return getSkillsInstallStatus();
}
export async function autoUpdateSkillsIfInstalled(): Promise<{
ran: boolean;
changedFiles: number;
processedSkills: number;
}> {
const targets = getSkillSyncTargets();
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
try {
await fs.access(installedMarker);
} catch {
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
try {
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill auto-update failed", { skillName, error });
},
});
if (result.changedFiles > 0) {
log.info("[integrations] auto-updated paseo skills", result);
} else {
log.info("[integrations] paseo skills already up to date", result);
}
return { ran: true, ...result };
} catch (error) {
log.warn("[integrations] auto-update skills aborted", { error });
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
const claudeDir = getClaudeSkillsDir();
const accessResults = await Promise.all(
SKILL_NAMES.map((skillName) =>
fs
.access(path.join(claudeDir, skillName, "SKILL.md"))
.then(() => true)
.catch(() => false),
),
);
return { installed: accessResults.every(Boolean) };
}
@@ -0,0 +1,10 @@
export {
getSkillsStatus,
installSkills,
uninstallSkills,
updateSkills,
type SkillOp,
type SkillsState,
type SkillsStatus,
type SkillTargets,
} from "./operations.js";
@@ -0,0 +1,302 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
isPackaged: false,
},
}));
import {
getSkillsStatus,
installSkills,
PASEO_SKILL_NAMES,
type SkillTargets,
uninstallSkills,
updateSkills,
} from "./operations";
interface Sandbox {
root: string;
targets: SkillTargets;
}
async function makeSandbox(): Promise<Sandbox> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
await fs.mkdir(targets.sourceDir, { recursive: true });
return { root, targets };
}
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
for (const [rel, content] of Object.entries(files)) {
const full = path.join(rootDir, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content);
}
}
async function writeBundleSkill(
sourceDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(sourceDir, name), files);
}
async function writeOnDiskSkill(
agentsDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(agentsDir, name), files);
}
async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
}
async function pathExists(p: string): Promise<boolean> {
return fs
.access(p)
.then(() => true)
.catch(() => false);
}
describe("getSkillsStatus", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
});
it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
});
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
});
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([
{ kind: "update", name: "paseo" },
{ kind: "delete", name: "paseo-chat" },
{ kind: "add", name: "paseo-loop" },
]);
});
});
describe("installSkills / updateSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
const status = await installSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
for (const name of ["paseo", "paseo-loop"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
}
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
).toBe("user-unslop");
});
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await updateSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
).toBe("paseo-v1");
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const first = await installSkills(sandbox.targets);
const second = await installSkills(sandbox.targets);
expect(first).toEqual({ state: "up-to-date", ops: [] });
expect(second).toEqual({ state: "up-to-date", ops: [] });
});
});
describe("uninstallSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await installSkills(sandbox.targets);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const name of PASEO_SKILL_NAMES) {
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
}
for (const name of ["unslop", "tdd", "devbox"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(`user-${name}`);
}
});
it("is a no-op when nothing is installed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
});
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
});
@@ -0,0 +1,164 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import {
getAgentsSkillsDir,
getBundledSkillsDir,
getClaudeSkillsDir,
getCodexSkillsDir,
} from "./paths.js";
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
}
export interface SkillTargets {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
export const PASEO_SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-chat",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
"paseo-orchestrator",
] as const;
type SkillFiles = Map<string, string>;
function resolveSkillTargets(): SkillTargets {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
const stat = await fs.stat(skillDir).catch(() => null);
if (!stat?.isDirectory()) return null;
const rels = await listFilesRecursive(skillDir);
const files: SkillFiles = new Map();
for (const rel of rels) {
const buf = await fs.readFile(path.join(skillDir, rel));
const sha = createHash("sha256").update(buf).digest("hex");
files.set(toPosix(rel), sha);
}
return files;
}
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
const out = new Map<string, SkillFiles>();
for (const name of PASEO_SKILL_NAMES) {
const files = await hashSkillDir(path.join(rootDir, name));
if (files !== null) out.set(name, files);
}
return out;
}
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name);
const d = disk.get(name);
if (b && !d) ops.push({ kind: "add", name });
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
else if (!b && d) ops.push({ kind: "delete", name });
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
if (a.size !== b.size) return false;
for (const [rel, sha] of a) {
if (b.get(rel) !== sha) return false;
}
return true;
}
function toPosix(p: string): string {
return p.split(path.sep).join("/");
}
function compareStrings(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
const ops = diff(bundle, disk);
if (disk.size === 0) return { state: "not-installed", ops };
if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops };
}
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets);
const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update")
.map((op) => op.name);
if (writes.length > 0) {
await syncSkills({
sourceDir: targets.sourceDir,
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
skillNames: writes,
});
}
for (const op of status.ops) {
if (op.kind !== "delete") continue;
await removeSkill(op.name, {
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
});
}
return getSkillsStatus(targets);
}
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) {
await removeSkill(name, {
agentsDir: t.agentsDir,
claudeDir: t.claudeDir,
codexDir: t.codexDir,
});
}
return getSkillsStatus(t);
}
@@ -0,0 +1,22 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
}
export function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
export function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
export function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { syncSkills } from "./skill-sync";
import { removeSkill, syncSkills } from "./sync";
interface Sandbox {
root: string;
@@ -70,6 +70,11 @@ describe("syncSkills", () => {
"utf-8",
);
expect(agentsContent).toBe("new paseo content");
const claudeContent = await fs.readFile(
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(claudeContent).toBe("new paseo content");
const codexContent = await fs.readFile(
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
"utf-8",
@@ -107,10 +112,10 @@ describe("syncSkills", () => {
),
).toBe("roles content");
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
const lstat = await fs.lstat(claudeLink);
expect(lstat.isSymbolicLink()).toBe(true);
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
"roles content",
);
});
@@ -254,3 +259,46 @@ describe("syncSkills", () => {
expect(result.processedSkills).toBe(0);
});
});
describe("removeSkill", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes the skill from all three targets when present", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
await removeSkill("paseo", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
});
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
});
it("does not throw when targets are missing", async () => {
await expect(
removeSkill("does-not-exist", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
}),
).resolves.toBeUndefined();
});
});
@@ -7,7 +7,6 @@ export interface SkillSyncOptions {
claudeDir: string;
codexDir: string;
skillNames: readonly string[];
platform?: NodeJS.Platform;
onSkillError?: (skillName: string, error: unknown) => void;
}
@@ -25,7 +24,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
return true;
}
async function listFilesRecursive(rootDir: string): Promise<string[]> {
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -53,38 +52,24 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
return changed;
}
async function ensureClaudeSkillLink(
skillName: string,
agentsDir: string,
claudeDir: string,
platform: NodeJS.Platform,
): Promise<number> {
await fs.mkdir(claudeDir, { recursive: true });
const target = path.join(agentsDir, skillName);
const linkPath = path.join(claudeDir, skillName);
export interface RemoveSkillTargets {
agentsDir: string;
claudeDir: string;
codexDir: string;
}
// Always rebuild the link rather than diffing it. fs.rm with force: true is
// a no-op when nothing is there, and matches existing install behavior.
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
// is preserved.
await fs.rm(linkPath, { recursive: true, force: true });
if (platform === "win32") {
try {
// Junctions don't require Developer Mode / admin like regular symlinks do.
await fs.symlink(target, linkPath, "junction");
return 0;
} catch {
return await syncDirectoryFiles(target, linkPath);
}
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
const paths = [
path.join(targets.agentsDir, skillName),
path.join(targets.claudeDir, skillName),
path.join(targets.codexDir, skillName),
];
for (const p of paths) {
await fs.rm(p, { recursive: true, force: true });
}
await fs.symlink(target, linkPath);
return 0;
}
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
const platform = options.platform ?? process.platform;
let changedFiles = 0;
let processedSkills = 0;
@@ -100,11 +85,9 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
path.join(options.agentsDir, skillName),
);
changedFiles += await ensureClaudeSkillLink(
skillName,
options.agentsDir,
options.claudeDir,
platform,
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.claudeDir, skillName),
);
changedFiles += await syncDirectoryFiles(
@@ -114,7 +97,8 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
processedSkills++;
} catch (error) {
options.onSkillError?.(skillName, error);
if (!options.onSkillError) throw error;
options.onSkillError(skillName, error);
}
}
-5
View File
@@ -48,7 +48,6 @@ import {
createBeforeQuitHandler,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
import { runDesktopStartup } from "./desktop-startup.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
@@ -661,10 +660,6 @@ async function bootstrap(): Promise<void> {
registerNotificationHandlers();
registerOpenerHandlers();
void autoUpdateSkillsIfInstalled().catch((error) => {
log.warn("[integrations] auto-update skills failed", error);
});
await createMainWindow();
app.on("activate", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"files": [
"dist"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.69",
"version": "0.1.70-beta.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -57,9 +57,9 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.70-beta.1",
"@getpaseo/relay": "0.1.70-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -116,17 +117,21 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("raw stderr line\n");
});
test("logs worker signal exits even when the worker cannot log", async () => {
const result = await runSupervisorFixture({
workerSource: `
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
test.skipIf(isPlatform("win32"))(
"logs worker signal exits even when the worker cannot log",
async () => {
const result = await runSupervisorFixture({
workerSource: `
process.kill(process.pid, "SIGKILL");
`,
});
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
},
);
});
@@ -1,7 +1,8 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
@@ -24,6 +25,9 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
interface LooseSafeParseResult {
success: boolean;
data: unknown;
@@ -42,7 +46,7 @@ interface LooseStructuredContent {
interface RegisteredMcpTool {
inputSchema: LooseInputSchema;
callback: (input: unknown) => Promise<{
handler: (input: unknown) => Promise<{
structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>;
}>;
@@ -383,7 +387,7 @@ describe("terminal MCP tools", () => {
});
const tool = registeredTool(server, "capture_terminal");
const response = await tool.callback({
const response = await tool.handler({
terminalId: "term-1",
scrollback: true,
stripAnsi: false,
@@ -507,7 +511,7 @@ describe("create_agent MCP tool", () => {
expect(providerWithEmptyProvider.success).toBe(false);
await expect(
tool.callback({
tool.handler({
cwd: existingCwd,
mode: "default",
title: "Short title",
@@ -570,7 +574,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.callback({
tool.handler({
cwd: "/path/that/does/not/exist",
title: "Short title",
provider: "codex/gpt-5.4",
@@ -583,7 +587,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -592,7 +596,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: " Fix auth bug ",
provider: "codex/gpt-5.4",
@@ -613,7 +617,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-456",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -622,7 +626,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: " Fix auth ",
provider: "codex/gpt-5.4",
@@ -642,7 +646,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-789",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -651,7 +655,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: "Config test",
mode: "auto",
@@ -685,14 +689,20 @@ describe("create_agent MCP tool", () => {
const startedAgentSetupIds: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-with-worktree",
@@ -717,7 +727,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
@@ -757,14 +767,20 @@ describe("create_agent MCP tool", () => {
};
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-named-worktree",
@@ -787,7 +803,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
@@ -798,7 +814,10 @@ describe("create_agent MCP tool", () => {
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(initialBranch).not.toBe("");
@@ -824,19 +843,28 @@ describe("create_agent MCP tool", () => {
};
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "existing-feature"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-checkout-worktree",
@@ -859,7 +887,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: repoDir,
title: "Checkout agent",
provider: "codex/gpt-5.4",
@@ -871,7 +899,9 @@ describe("create_agent MCP tool", () => {
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim(),
).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -901,7 +931,7 @@ describe("create_agent MCP tool", () => {
},
workspace: {
workspaceId: "/tmp/worktrees/pr-123",
projectId: "/tmp/repo",
projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const,
displayName: "pr-123",
@@ -909,7 +939,7 @@ describe("create_agent MCP tool", () => {
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
},
repoRoot: "/tmp/repo",
repoRoot: REPO_CWD,
created: true,
...(options?.setupContinuation?.kind === "agent"
? {
@@ -948,8 +978,8 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: "/tmp/repo",
await tool.handler({
cwd: REPO_CWD,
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
@@ -985,14 +1015,20 @@ describe("create_agent MCP tool", () => {
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
};
@@ -1013,7 +1049,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
const response = await tool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
});
@@ -1031,19 +1067,27 @@ describe("create_agent MCP tool", () => {
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
);
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
@@ -1072,13 +1116,13 @@ describe("create_agent MCP tool", () => {
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.callback({
const created = await createTool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
});
workspaceGitService.getSnapshot.mockClear();
await archiveTool.callback({
await archiveTool.handler({
cwd: repoDir,
worktreePath: created.structuredContent.worktreePath,
});
@@ -1126,9 +1170,9 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "list_worktrees");
const response = await tool.callback({ cwd: "/tmp/repo" });
const response = await tool.handler({ cwd: REPO_CWD });
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
reason: "mcp:list-worktrees",
});
expect(response.structuredContent.worktrees).toEqual([
@@ -1188,7 +1232,7 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: "subdir",
title: "Child",
provider: "codex/gpt-5.4",
@@ -1214,7 +1258,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-injected-123",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -1227,7 +1271,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: "Injected config test",
mode: "auto",
@@ -1251,7 +1295,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.callback({
tool.handler({
cwd: existingCwd,
title: "Bad mode",
provider: "opencode/gpt-5.4",
@@ -1288,7 +1332,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
title: "Child",
provider: "claude/claude-sonnet-4-20250514",
initialPrompt: "Do work",
@@ -1319,7 +1363,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.callback({
tool.handler({
title: "Child",
provider: "opencode/gpt-5.4",
initialPrompt: "Do work",
@@ -1354,7 +1398,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
title: "Child",
provider: "opencode/gpt-5.4",
mode: "build",
@@ -1384,7 +1428,7 @@ describe("create_schedule MCP tool", () => {
const tool = registeredTool(server, "create_schedule");
await expect(
tool.callback({
tool.handler({
prompt: "say hello",
every: "5m",
name: "Default schedule",
@@ -1404,12 +1448,12 @@ describe("create_schedule MCP tool", () => {
});
const tool = registeredTool(server, "create_schedule");
await tool.callback({
await tool.handler({
prompt: "say hello",
every: "5m",
provider: "codex",
});
await tool.callback({
await tool.handler({
prompt: "say hello again",
every: "10m",
provider: "codex/gpt-5.4",
@@ -1470,7 +1514,7 @@ describe("provider listing MCP tool", () => {
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.callback({});
const response = await tool.handler({});
expect(response.structuredContent).toEqual({
providers: [
@@ -1518,7 +1562,7 @@ describe("provider listing MCP tool", () => {
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.callback({});
const response = await tool.handler({});
expect(response.structuredContent).toEqual({
providers: [
@@ -1555,7 +1599,7 @@ describe("provider listing MCP tool", () => {
});
const tool = registeredTool(server, "list_providers");
await tool.callback({});
await tool.handler({});
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
expect(isAvailable).toHaveBeenCalledTimes(1);
@@ -1591,7 +1635,7 @@ describe("model listing MCP tool", () => {
});
const tool = registeredTool(server, "list_models");
await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
expect(fetchModels).not.toHaveBeenCalled();
@@ -1615,7 +1659,7 @@ describe("speak MCP tool", () => {
const tool = registeredTool(server, "speak");
expect(tool).toBeDefined();
await tool.callback({ text: "Hello from voice agent." });
await tool.handler({ text: "Hello from voice agent." });
expect(speak).toHaveBeenCalledWith(
expect.objectContaining({
text: "Hello from voice agent.",
@@ -1635,7 +1679,7 @@ describe("speak MCP tool", () => {
logger,
});
const tool = registeredTool(server, "speak");
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
"No speak handler registered for caller agent",
);
});
@@ -1662,7 +1706,7 @@ describe("agent snapshot MCP serialization", () => {
createManagedAgent({
id: "agent-compact",
provider: "codex",
cwd: "/tmp/repo",
cwd: REPO_CWD,
config: { model: "gpt-5.4", thinkingOptionId: "high" },
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
labels: { role: "researcher" },
@@ -1671,7 +1715,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({});
const response = await tool.handler({});
const structured = z
.object({ agents: z.array(z.record(z.unknown())) })
.parse(response.structuredContent);
@@ -1687,7 +1731,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: "high",
effectiveThinkingOptionId: "high",
status: "idle",
cwd: "/tmp/repo",
cwd: REPO_CWD,
createdAt: expect.any(String),
updatedAt: expect.any(String),
lastUserMessageAt: null,
@@ -1725,7 +1769,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_status");
const response = await tool.callback({ agentId: "archived-agent" });
const response = await tool.handler({ agentId: "archived-agent" });
expect(response.structuredContent).toEqual({
status: "closed",
@@ -1781,7 +1825,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "get_agent_status");
const response = await tool.callback({ agentId: "full-detail-agent" });
const response = await tool.handler({ agentId: "full-detail-agent" });
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
@@ -1857,7 +1901,7 @@ describe("agent snapshot MCP serialization", () => {
});
const tool = registeredTool(server, "get_agent_status");
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow(
"Agent internal-agent not found",
);
});
@@ -1901,7 +1945,7 @@ describe("agent snapshot MCP serialization", () => {
callerAgentId: "caller-agent",
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({});
const response = await tool.handler({});
const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(3);
@@ -1916,28 +1960,32 @@ describe("agent snapshot MCP serialization", () => {
spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({
id: "running-target",
cwd: "/tmp/target",
cwd: TARGET_CWD,
lifecycle: "running",
updatedAt: new Date(recent),
}),
createManagedAgent({
id: "idle-target",
cwd: "/tmp/target",
cwd: TARGET_CWD,
lifecycle: "idle",
updatedAt: new Date(recent),
}),
createManagedAgent({
id: "old-running-target",
cwd: "/tmp/target",
cwd: TARGET_CWD,
lifecycle: "running",
createdAt: new Date(old),
updatedAt: new Date(old),
}),
]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
createStoredRecord({
id: "recent-other-cwd",
cwd: resolvePath("/tmp/other"),
archivedAt: recent,
}),
]);
const server = await createAgentMcpServer({
@@ -1949,8 +1997,8 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({
cwd: "/tmp/target",
const response = await tool.handler({
cwd: TARGET_CWD,
includeArchived: true,
sinceHours: 48,
statuses: ["running", "closed"],
@@ -1990,7 +2038,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ includeArchived: true });
const response = await tool.handler({ includeArchived: true });
const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(50);
@@ -2009,7 +2057,7 @@ describe("agent snapshot MCP serialization", () => {
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({
id: "stored-archived-compact",
cwd: "/tmp/repo",
cwd: REPO_CWD,
updatedAt: now,
lastActivityAt: now,
archivedAt: now,
@@ -2033,7 +2081,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true });
const item = agentsOf(response)[0];
expect(item).toEqual({
@@ -2045,7 +2093,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: null,
effectiveThinkingOptionId: null,
status: "closed",
cwd: "/tmp/repo",
cwd: REPO_CWD,
createdAt: "2026-04-11T00:00:00.000Z",
updatedAt: now,
lastUserMessageAt: null,
@@ -2106,7 +2154,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({});
const response = await tool.handler({});
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
"idle-attention-oldest",
@@ -2141,7 +2189,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ includeArchived: true });
const response = await tool.handler({ includeArchived: true });
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
if (!parsed.success) {
@@ -2181,7 +2229,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "archived-activity-agent" });
const response = await tool.handler({ agentId: "archived-activity-agent" });
expect(response.structuredContent).toEqual(
expect.objectContaining({
@@ -2219,7 +2267,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 });
const content = String(response.structuredContent.content);
expect(content).toContain("Hello world. How are you?");
@@ -2250,7 +2298,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 });
const content = String(response.structuredContent.content);
expect(content).toContain("[User] u3");
@@ -74,7 +74,14 @@ async function createSession(params?: {
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
try {
rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
throw error;
}
}
}
async function startTurnAndCollectEvents(
@@ -1,4 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, test, vi } from "vitest";
import type {
PermissionOption,
PromptResponse,
@@ -23,6 +25,7 @@ import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import * as spawnUtils from "../../../utils/spawn.js";
interface ACPSessionInternals {
sessionId: string | null;
@@ -114,6 +117,14 @@ function createSessionWithConfig(
);
}
function createTerminalChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
child.stdout = new EventEmitter() as ChildProcess["stdout"];
child.stderr = new EventEmitter() as ChildProcess["stderr"];
child.kill = vi.fn(() => true) as ChildProcess["kill"];
return child;
}
function selectConfigOption(
category: "mode" | "model" | "thought_level",
values: string[],
@@ -303,6 +314,78 @@ describe("createLoggedNdJsonStream", () => {
});
});
describe("ACPAgentSession terminal tools", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("runs single-string terminal commands through the platform shell", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const shell = spawnUtils.platformShell();
await session.createTerminal({
sessionId: "session-1",
command: "git -C /repo status --short",
cwd: "/repo",
});
expect(spawn).toHaveBeenCalledWith(
shell.command,
[...shell.flag, "git -C /repo status --short"],
expect.objectContaining({ cwd: "/repo" }),
);
});
test("preserves explicit terminal argv", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
await session.createTerminal({
sessionId: "session-1",
command: "git",
args: ["status", "--short"],
cwd: "/repo",
});
expect(spawn).toHaveBeenCalledWith(
"git",
["status", "--short"],
expect.objectContaining({ cwd: "/repo" }),
);
});
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
const child = createTerminalChildStub();
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: "missing-command",
});
child.emit("error", new Error("spawn missing-command ENOENT"));
await expect(
session.waitForTerminalExit({
sessionId: "session-1",
terminalId: terminal.terminalId,
}),
).rejects.toThrow("spawn missing-command ENOENT");
await expect(
session.terminalOutput({
sessionId: "session-1",
terminalId: terminal.terminalId,
}),
).resolves.toMatchObject({
output: "spawn missing-command ENOENT\n",
truncated: false,
});
});
});
describe("mapACPUsage", () => {
test("maps ACP usage fields into Paseo usage", () => {
expect(
@@ -92,7 +92,7 @@ import {
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
function assertChildWithPipes(
child: ChildProcess,
@@ -106,6 +106,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function resolveTerminalCommand(
command: string,
args?: string[],
): { command: string; args: string[] } {
if (args && args.length > 0) {
return { command, args };
}
if (!/\s/.test(command.trim())) {
return { command, args: [] };
}
const shell = platformShell();
return { command: shell.command, args: [...shell.flag, command] };
}
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -1464,7 +1480,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const env = Object.fromEntries(
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
);
const child = spawnProcess(params.command, params.args ?? [], {
const terminalCommand = resolveTerminalCommand(params.command, params.args);
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
cwd: params.cwd ?? this.config.cwd,
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
@@ -1479,6 +1496,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolveExit = resolve;
rejectExit = reject;
});
waitForExit.catch(() => undefined);
const entry: TerminalEntry = {
id: terminalId,
@@ -1498,9 +1516,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()),
);
child.once("error", (error) =>
rejectExit(error instanceof Error ? error : new Error(String(error))),
);
child.once("error", (error) => {
const spawnError = error instanceof Error ? error : new Error(String(error));
appendTerminalOutput(entry, `${spawnError.message}\n`);
rejectExit(spawnError);
});
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
entry.exit = exit;
@@ -66,6 +66,7 @@ describe("Claude SDK env", () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession(
{
@@ -123,6 +124,7 @@ describe("Claude SDK env", () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.resumeSession(
{
@@ -6,11 +6,25 @@ import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
type SpawnClaudeOptions = NonNullable<
Parameters<typeof query>[0]["options"]
>["spawnClaudeCodeProcess"];
const spawnClaudeForTest: SpawnClaudeOptions = (options) =>
spawnProcess(options.command, options.args, {
cwd: options.cwd,
env: options.env,
signal: options.signal,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
@@ -157,7 +171,14 @@ async function createSession(params?: {
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
try {
rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
throw error;
}
}
}
describe("ClaudeAgentSession integration", () => {
@@ -223,6 +244,8 @@ describe("ClaudeAgentSession integration", () => {
}, 60_000);
test("supportedModels returns the current abstract Claude SDK model shape", async () => {
const claudeBinary = await findExecutable("claude");
if (!claudeBinary) throw new Error("claude binary required for this integration test");
const claudeQuery = query({
prompt: createEmptyPrompt(),
options: {
@@ -230,6 +253,8 @@ describe("ClaudeAgentSession integration", () => {
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"],
pathToClaudeCodeExecutable: claudeBinary,
spawnClaudeCodeProcess: spawnClaudeForTest,
},
});

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