Compare commits

..

64 Commits

Author SHA1 Message Date
Mohamed Boudra
b0a0cb4a99 chore(release): cut 0.1.85 2026-05-29 00:09:30 +07:00
Mohamed Boudra
342e92d0c7 Update changelog for 0.1.85 2026-05-29 00:06:48 +07:00
Mohamed Boudra
0170eba233 Add Opus 4.8 to the Claude model picker
Opus 4.8 (and its 1M-context variant) become the default Claude
models, pushing Opus 4.6 off the default slot. Opus 4.7 stays in
the list as the previous release.
2026-05-29 00:03:39 +07:00
Mohamed Boudra
b6103a59da Archive agents on worktree archive; clean up schedules on archive (#1206)
* Archive agents on worktree archive; clean up schedules on archive

Worktree archive used to hard-delete every agent inside it (storage
removed, agent_deleted emitted), losing history along with the worktree.
It now archives those agents instead, so they remain in storage with an
archivedAt timestamp and stay visible in the archived list.

Schedules targeting an archived agent were left as dead records that the
schedule service rejected at run time. AgentManager now fires an
onAgentArchived callback from every archive path (live, stored snapshot,
cascade-to-children), and bootstrap wires it to
ScheduleService.deleteForAgent so those schedules are cleaned up
automatically.

* Push agent_state when archiving stored-only agents; tolerate schedule delete errors

Greptile review on PR #1206 flagged two issues:

1. archiveSnapshot persisted archivedAt to storage but did not emit an
   agent_state event for stored-only agents. Connected clients would
   keep showing them as active until a full reload. Mirror the dispatch
   logic from markRecordArchived: notifyAgentState for live agents,
   dispatchArchivedStoredAgent for off-memory non-internal agents.

2. ScheduleService.deleteForAgent used Promise.all, which short-circuits
   on the first store error and abandons the rest. Switch to
   Promise.allSettled, count fulfilled deletions, and log rejections.

* Tighten agent-manager archive tests after /audit-tests review

- Split "fires onAgentArchived for live, stored, and cascaded archives"
  into two single-behavior tests with independent fixtures, so the assertion
  array isn't reset mid-test.
- Strict equality on the cascade hook assertion (no more arrayContaining).
- "archiveSnapshot dispatches archived state" now captures the full agent
  state event, asserts a dispatch happened, asserts the dispatched agent
  id, and asserts the lifecycle is closed.
2026-05-28 23:02:58 +08:00
Mohamed Boudra
24526a3b23 Move branch-slug to @getpaseo/protocol
The app must not import @getpaseo/server. Moving the slug validation
and slugify helpers into @getpaseo/protocol (already a shared dep of
app, client, server) gives every package a legitimate home for the
import. Drops the corresponding ./utils/branch-slug subpath from
@getpaseo/server's exports map.

Unblocks the Deploy App workflow, which only builds protocol/client/
audio for the app — server's dist never exists in that pipeline.
2026-05-28 18:41:13 +07:00
paseo-ai[bot]
6d1aa1f415 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 11:15:56 +00:00
Mohamed Boudra
4b973ae9ec chore(release): cut 0.1.84 2026-05-28 18:11:58 +07:00
Mohamed Boudra
e02e8426d4 Update changelog for 0.1.84 2026-05-28 18:10:30 +07:00
Mohamed Boudra
e4188f5222 Document the two-step release flow
The release has two steps: preparation, which the agent does locally and
reversibly, and go-ahead, which only the user authorizes. Last-minute
changes always need approval, code changes never bundle into the
changelog or release commit, and a sanity-check finding is information
for the user — not a directive for the agent to act on.
2026-05-28 18:08:54 +07:00
Yurui Zhou
8262fb42af Fix/pi ask user submit (#1188)
* Fix Pi ask_user optional input handling

* Clarify Pi ask_user optional comment prompt

* Combine Pi ask_user optional comment UI
2026-05-28 19:08:38 +08:00
Mohamed Boudra
3176f844e7 Make MCP provider controls match the app (#1198)
* Align MCP provider controls with app provider state

* Deepen provider snapshot routing

* Make provider snapshots the daemon authority

* Move provider shutdown behind a generic AgentClient seam

OpenCodeServerManager is now owned entirely by the OpenCode provider.
ProviderSnapshotManager.shutdown() and provider-registry.shutdownProviders()
materialize enabled clients and call an optional shutdown() per client; the
OpenCode client forwards to its runtime. Other providers ignore it.

Also wires providerSnapshotManager into the remaining Session-constructing
tests that were missing it (server-tests CI failure).

* Use platform-native cwd in change-event test
2026-05-28 18:25:09 +08:00
Mohamed Boudra
8234ecb7ba Upload only mac DMGs as workflow artifacts 2026-05-28 16:20:58 +07:00
Mohamed Boudra
adb9a57cfc Build server before desktop app export 2026-05-28 15:50:43 +07:00
paseo-ai[bot]
93c14cb8ad fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 08:44:09 +00:00
Mohamed Boudra
a00152290f Upload desktop workflow artifacts 2026-05-28 15:40:37 +07:00
Mohamed Boudra
8e4cbf8ca6 Simplify local speech models 2026-05-28 15:40:37 +07:00
Mohamed Boudra
0737c5c973 Stop publishing package debug artifacts 2026-05-28 15:40:36 +07:00
Mohamed Boudra
c4f9874e1f Fix archive redirect route typing 2026-05-28 15:40:36 +07:00
Mohamed Boudra
990bca71b7 Polish blog post metadata 2026-05-28 15:40:36 +07:00
Mohamed Boudra
f431ebee6d Add Electron migration post 2026-05-28 15:40:36 +07:00
Mohamed Boudra
93189148f5 Fix mobile code block rendering 2026-05-28 15:40:36 +07:00
Mohamed Boudra
3baba543a4 Add opencode resume command 2026-05-28 15:40:36 +07:00
Mohamed Boudra
f20393dbb7 Bound workspace git service caches to fix daemon memory leak (#1200)
The seven Map-based caches in WorkspaceGitServiceImpl had no eviction —
the 15s TTL only marked entries stale before refetching in place. Long-
lived daemons accumulated entries for every ephemeral worktree cwd that
ever ran, with checkoutDiffCache holding multi-MB highlighted diffs.

Switch to LRUCache. checkoutDiffCache caps at 64 (heavy values); the
other six aux caches cap at 256. Stale-in-place TTL semantics are
unchanged.
2026-05-28 16:38:50 +08:00
Mohamed Boudra
3ac182cffc Improve app tests (#1197)
* docs(testing): require ports-and-adapters unit tests or real e2e — no in-between

State the end-state explicitly so the test-suite cleanup has a written bar.

* Make useProjects a ports-and-adapters unit test

Extract the per-host workspace aggregation out of useProjects into a
pure fetchAggregatedProjects(input) that takes a typed ProjectsRuntime
adapter. The hook becomes a thin useQuery shim.

The test loses jsdom, @testing-library/react, QueryClientProvider, and
all vi.mock/vi.hoisted of @/runtime/host-runtime. It now exercises the
real aggregation against an in-memory ProjectsRuntime adapter.

* Make useLoadOlderAgentHistory a ports-and-adapters unit test

Extract the load-older sequence into a pure async function that takes
its client, in-flight tracker, toast, and logger as injected
dependencies. The hook reads the session store at call time and wires
the real adapters in. Tests now drive the pure function with typed
fakes — no JSDOM, no @testing-library/react renderHook, no console
spy.

* Make useAgentHistory a ports-and-adapters unit test

Drop the dead __private__ namespace from use-agent-history.ts, expose
fetchAgentHistoryPage and an AgentHistoryClient port as normal exports.
The hook keeps its useInfiniteQuery wiring; the page fetcher is now
testable in isolation.

The test loses jsdom, @testing-library/react, QueryClientProvider, the
vi.hoisted/vi.mock of @/runtime/host-runtime, and the renderHook timing
loops. It exercises the page fetcher directly against an in-memory
AgentHistoryClient adapter that records each call.

* Make usePrPaneData a ports-and-adapters unit test

Extract the timeline fetch into a pure fetchPrPaneTimelinePage that
takes a PrPaneTimelineClient adapter and an UnsupportedTimelineRegistry
port. Extract the rest of the hook's wiring into pure exports:
extractPrRepoIdentity, shouldFetchTimelineFrom, selectPrPaneState. The
module-level unsupported-tuple Set becomes a swappable in-memory
registry; production still wires the same module-level default.

The test loses JSDOM, vi.hoisted + vi.mock of @/runtime/host-runtime,
QueryClientProvider, createRoot, act, focusManager/onlineManager
toggling, and a hand-rolled waitForExpectation polling loop. It drives
each pure function and the timeline fetcher directly against typed
fakes and asserts on recorded state.

* Make useProvidersSnapshot a ports-and-adapters unit test

Extract the network and cache work into pure functions taking a
typed ProvidersSnapshotClient adapter: fetchProvidersSnapshot,
refreshAndApplyProvidersSnapshot, applyProvidersSnapshotUpdate, and
selectorOpenRefetchDecision. The hook still drives them through
react-query and the host-runtime websocket subscription; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, act, and a
hand-rolled listener-bucket trap that reached into the spied
client.on subscriber. It drives each pure function directly against
a typed FakeProvidersSnapshotClient and a real QueryClient.

* Make useSettings a ports-and-adapters unit test

Extract the pure load/save logic into use-settings.pure.ts taking a
typed SettingsDeps adapter: a KeyValueStorage port and a
DesktopSettingsBridge port. The hook file keeps the public 0-arg API
(loadAppSettingsFromStorage, loadSettingsFromStorage, persistAppSettings,
saveAppSettings) by wrapping the pure functions with productionDeps that
wire AsyncStorage, isElectronRuntime, loadDesktopSettings, and
migrateLegacyDesktopSettings as before.

The test loses three vi.mock blocks (async-storage, @/desktop/host,
@/desktop/settings/desktop-settings), two vi.hoisted blobs, and the
vi.resetModules + await import("./use-settings") per-test pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage and a FakeDesktopBridge that records applied
migrations, asserting on observable storage state rather than mock
function calls.

* Make useAgentCommandsQuery a ports-and-adapters unit test

Extract the daemon call into fetchAgentCommands, a pure async
function taking a typed AgentCommandsClient adapter. The hook still
wraps it in useQuery and resolves the host-runtime client; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, and
waitFor. It calls fetchAgentCommands directly against a typed
FakeAgentCommandsClient.

* Make useChangesPreferences a ports-and-adapters unit test

Extract the AsyncStorage-touching load and save logic into
use-changes-preferences.pure.ts taking a typed KeyValueStorage adapter.
The hook file keeps the public API (useChangesPreferences,
loadChangesPreferencesFromStorage) by wrapping the pure functions with a
productionStorage wired to AsyncStorage as before.

The test loses vi.hoisted + vi.mock of
@react-native-async-storage/async-storage, vi.resetModules, and the
per-test dynamic await import("./use-changes-preferences") pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage, asserting on observable storage entries rather
than mock function calls. Adds coverage for saveChangesPreferences,
including the no-prior-cache fallback path.

* Make useArchiveAgent a ports-and-adapters unit test

Extract the queryClient-only pure helpers (toArchiveKey,
selectPendingArchiveAgentIds, setAgentArchiving, isAgentArchiving,
removeAgentFromListPayload, markAgentArchivedInHistoryPayload, and the
queryClient-mutating cache helpers) into use-archive-agent.pure.ts. The
hook file keeps the React surface (usePendingArchiveAgentIds,
useArchiveAgent, applyArchivedAgentCloseResults) by importing from the
pure module, and the __private__ reach-around export is gone.

The test loses @vitest-environment jsdom, @testing-library/react, the
renderHook/act/waitFor imports, and the __private__ reach-around. It
calls the pure helpers directly against a real QueryClient and the real
session store. The renderHook test of usePendingArchiveAgentIds is
dropped — it asserted on react-query's subscription mechanics rather
than on our logic.

* Make useSidebarWorkspacesList a ports-and-adapters unit test

Extract the pure pieces — types, applyStoredOrdering,
appendMissingOrderKeys, buildSidebarProjectsFromStructure, and the new
computeSidebarOrderUpdates + deriveSidebarLoadingState helpers — into
use-sidebar-workspaces-list.pure.ts. The hook reads its persistent
sidebar order via the pure helper inside useEffect and derives its
loading state via the pure helper, instead of inlining the logic across
two effects. createSidebarWorkspaceEntry stays in the hook file since
it pulls selectPrHintFromStatus, but the hook re-exports it for
existing callers.

The test loses @vitest-environment jsdom, react-dom/client,
@testing-library/react, and the three Probe components that mounted
React just to assert on effect mechanics. It calls
computeSidebarOrderUpdates and deriveSidebarLoadingState directly, with
no module mocks or hoisted globals. The "does not subscribe while
disabled" assertion is dropped — it was testing useSyncExternalStore's
subscribe gate, not our logic.

* Make useAgentInitialization a ports-and-adapters unit test

Extract ensureAgentIsInitialized and refreshAgent — the entire
imperative bodies of the hook's two callbacks — into
use-agent-initialization.pure.ts, taking setAgentInitializing as an
injected port. Add createSetAgentInitializing as a factory that binds
serverId to the zustand setInitializingAgents action. The hook itself
collapses to ~20 lines of useMemo + useCallback bindings.

The test drops @vitest-environment jsdom, @testing-library/react,
renderHook, and act. It calls ensureAgentIsInitialized and refreshAgent
directly with a bound setAgentInitializing fake. No React mounting, no
module mocks, no hoisted globals.

* Make useClientActivity a ports-and-adapters unit test

Extract the activity-tracker state machine — lastActivityAt bookkeeping,
heartbeat throttling, app-visibility transitions, system-idle monotonic
update, and focused-agent change handling — into
use-client-activity.pure.ts. createClientActivityTracker takes the
heartbeat client, deviceType, and a now() port; the hook wires DOM /
AppState / Electron-idle listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, and four module mocks (@/constants/platform, @/desktop/electron/idle,
react-native, @getpaseo/client/internal/daemon-client). It calls the
tracker directly with a fake heartbeat client and a test clock; the
fake records emitted heartbeats as observable state.

* Make useHoverSafeZone a ports-and-adapters unit test

Extract the safe-zone state machine — wasInside dedupe, bridge-rect
geometry between trigger and content, and inside/outside transitions —
into use-hover-safe-zone.pure.ts. createHoverSafeZoneTracker takes
getTriggerRect / getContentRect / onEnterSafeZone / onLeaveSafeZone;
the hook just wires document pointermove, window pointerout, and
window blur listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, @testing-library/react renderHook, the vi.mock("@/constants/platform")
shim, the IS_REACT_ACT_ENVIRONMENT stub, and the getBoundingClientRect
patching helper. It calls the tracker directly with fake rect getters
and asserts on recorded enter/leave counts.

* Make useArchiveSubagent a ports-and-adapters unit test

* Make openImagePathsWithDesktopDialog a ports-and-adapters unit test

Inject the DesktopDialogBridge into openImagePathsWithDesktopDialog instead
of reaching for getDesktopHost() inside the function. useImageAttachmentPicker
passes getDesktopHost()?.dialog at the call site; the native sibling matches
the new signature.

The test drops vi.mock("@/desktop/host", ...) and vi.hoisted() in favor of a
typed in-memory fake dialog that records the options it was called with. No
global module substitution, no spies — the test reads the fake's recorded
state.

* Make useIosHardwareKeyboardSubmit a ports-and-adapters unit test

* Make UpdateCalloutSource a ports-and-adapters unit test

Extract resolveUpdateCalloutDescriptor as a pure function that maps
updater state to a structured callout descriptor. UpdateCalloutSource
becomes a thin React shim that materializes the descriptor's icon and
description as ReactNodes before registering with the sidebar callout
API.

Replaces a 259-line JSDOM + react-dom/client test that mocked five
modules (unistyles theme, lucide icons, async-storage, openExternalUrl,
useDesktopAppUpdater) and mounted SidebarCalloutProvider/Slot just to
assert deterministic title/description/action/dismissal-key derivations.
The new test exercises the resolver directly with zero React, zero DOM,
zero mocks.

* Delete dead useWorkspaceNavigation hook + collapse re-export indirection

The hook had zero production callers — only its own test, which used
vi.hoisted + vi.mock + jsdom + @testing-library/react/renderHook to
verify a useCallback wrapper. The file also re-exported navigateToWorkspace
from the navigation store, so five production importers and two sibling
tests reached the store through a hook-module path that had nothing to
do with hooks.

Retargets every importer to @/stores/navigation-active-workspace-store
directly and removes the indirection module + its slop test.

* Make useCheckoutStatusQuery a ports-and-adapters unit test

Extract peekOrFetchCheckoutStatus and applyCheckoutStatusUpdate to a
sibling checkout-status-cache.ts so both pure functions operate on an
injected QueryClient and CheckoutStatusClient, with no React or host
runtime imports. useCheckoutStatusQuery becomes a thin shell that
composes useQuery + useEffect and delegates the cwd-filter + cache write
to applyCheckoutStatusUpdate.

Replaces a 318-line JSDOM + react-dom/client + fake-timers test that
mocked @/runtime/host-runtime via vi.hoisted, mounted a Probe component
to read the hook's data, and captured the subscription handler in a
hoisted Set. The new test exercises both functions directly against a
real QueryClient — zero React, zero DOM, zero mocks, zero fake timers.

* Make workspace-navigation a ports-and-adapters unit test

* Make navigateToAgent a ports-and-adapters unit test

* Make redirectIfArchivingActiveWorkspace a ports-and-adapters unit test

* Make openProjectDirectly a ports-and-adapters unit test

* Make navigation-active-workspace-store a ports-and-adapters unit test

* Make desktop-attachment-store a ports-and-adapters unit test

* Make readDesktopSystemIdleTimeMs a ports-and-adapters unit test

Rename getDesktopSystemIdleTimeMs to readDesktopSystemIdleTimeMs and
take the desktop IPC invoker as a parameter. The single caller in
use-client-activity.ts now imports invokeDesktopCommand directly and
passes it; the test wires a typed fake invoker.

Drops vi.mock("@/desktop/electron/invoke"), vi.hoisted, and the four
vi.spyOn(console, "warn") log assertions. Tests now assert the
documented behaviour (returns ms, or null) against an in-memory fake.

* Make useDesktopAppUpdater a ports-and-adapters unit test

Extract the check/install state machine from the React hook into a pure
createDesktopAppUpdater runtime that exposes getSnapshot/subscribe/
checkForUpdates/installUpdate. The hook now wires real production deps
into the runtime and bridges its snapshot via useSyncExternalStore;
React-driven concerns (pending-update interval, initial silent check)
stay in the hook.

Drops JSDOM, @testing-library/react, renderHook, the three vi.mock
calls and the vi.hoisted state shim from the test. The new test wires
a typed FakeDesktopAppUpdaterPort recording recordedChecks/recordedInstalls
and exposes deferNextCheck/failNextCheck/nextInstallResult so the
behaviour assertions read as plain English (status transitions through
checking, available, pending, up-to-date, error; race cancellation drops
older results; install errors get reported once).

* Make sidebar-collapsed-sections-store a ports-and-adapters unit test

* Make session-store-hooks a ports-and-adapters unit test

* Make panel-store a ports-and-adapters unit test

* Make workspace-tabs-store a ports-and-adapters unit test

* Make desktop-preview-url a ports-and-adapters unit test

* Make client-id a ports-and-adapters unit test

* Make browser-store a ports-and-adapters unit test

* Make local-file-attachment-store a ports-and-adapters unit test

* Make draft-store a ports-and-adapters unit test

* Make rich-clipboard a ports-and-adapters unit test

* Make desktop-daemon-transport a ports-and-adapters unit test

* Make image-attachment-picker.native a ports-and-adapters unit test

Extract the pure normalize logic into image-attachment-picker.native.pure.ts
and take the PNG exporter as a port. The .native.ts entry wires the real
expo-image-manipulator adapter; the test wires a fake exporter and asserts
on its recorded uris.

Drops vi.mock("expo-image-manipulator") and the inline ImageManipulator
stub from the test. Production callers of normalizePickedImageAssets are
unchanged.

* Make tool-call-icon a ports-and-adapters unit test

Split the pure icon-identity decision into tool-call-icon-name.ts and
keep the lucide/PaseoLogo component lookup in tool-call-icon.ts. The
resolver returns a ToolCallIcon string ("bot", "brain", "paseo", ...);
componentForToolCallIcon does the React component mapping; the existing
resolveToolCallIcon is the composition.

Drops vi.mock("lucide-react-native") from the test and the brittle
"expect(icon).toBe(iconMocks.Bot)" pattern — the unit project couldn't
evaluate lucide-react-native, which is why the mock existed in the
first place. The test now asserts on string identifiers and imports
only the pure module.

buildToolCallPresentation's resolveIcon port is unchanged; the single
caller (components/message.tsx) keeps passing resolveToolCallIcon and
gets the same component back.

* Make desktop-permissions a ports-and-adapters unit test

The test was the heaviest globalThis-juggling test in the app package:
vi.doMock("react-native") + vi.resetModules() per case to swap the
platform, ensureWindow/setNavigator/restoreGlobals to swap
globalThis.Notification, globalThis.navigator, and window.paseoDesktop.
That whole setup existed because the production module reached into
four ambient sources (Notification, navigator, getDesktopHost(),
isWeb/isNative) without a port.

Extract a DesktopPermissionEnvironment interface — { isWeb,
getDesktopHost, getNotification, getNavigator } — and rebuild the
module around a createDesktopPermissions(env) factory. The real
environment binds to the actual globals at module load and the
existing top-level exports (shouldShowDesktopPermissionSection,
getDesktopPermissionSnapshot, requestDesktopPermission) are thin
references onto it, so the only caller (use-desktop-permissions.ts) is
unchanged.

The test now constructs a fakeEnvironment per case and calls into
createDesktopPermissions directly — no JSDOM, no vi.doMock, no
vi.resetModules, no globalThis writes. The eight behaviors are
preserved.

* Make provider-icons a ports-and-adapters unit test

Split the pure provider→icon-identity decision into provider-icon-name.ts
and keep the lucide/SvgXml/catalog component lookup in provider-icons.ts.
resolveProviderIconName returns a ProviderIconName ({kind:"builtin"|"catalog"|"bot", id?}),
and getProviderIcon composes it with the catalog/builtin component maps.

Drops vi.mock("lucide-react-native") from the test — the previous
"expect(icon).toBe(iconMocks.Bot)" pattern only existed because the unit
project couldn't evaluate lucide-react-native. The new test asserts on
discriminated-union identifiers and imports only the pure module.

The 13 outside callers of getProviderIcon are unchanged.

* Make crypto polyfill a ports-and-adapters unit test

* Delete redundant JSDOM Index route test

packages/app/src/app/index.test.tsx mounted the Index component through
JSDOM + createRoot + @testing-library/react, mocked five modules
(expo-router, _layout, desktop-daemon, startup-splash-screen,
navigation-active-workspace-store), and asserted that <Redirect> got
rendered with the right href across six scenarios.

Each of those six scenarios is a one-to-one duplicate of a pure case
already covered in host-runtime-bootstrap.test.ts, which tests the same
two decision functions (resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection) directly. The component is pure
wiring: it reads four hooks, calls the two decision functions, and
renders the result. There is no logic in Index to verify that the pure
tests do not already cover.

Drop the JSDOM file; the wiring is covered by app E2E.

* Make review-draft-store a ports-and-adapters unit test

* Make new-workspace-empty a ports-and-adapters unit test

* Extract shared drag-reorder state machine for web sortable lists

* Extract pure subagents track presentation helpers

Move formatHeaderLabel and resolveRowLabel out of track.tsx into a
colocated track-presentation.ts. Six header-copy tests that previously
mounted React under JSDOM with eight vi.mocks now run as plain unit
tests against the pure helpers.

* Make terminal-file-drop a ports-and-adapters unit test

* Extract pure import-session-sheet view-model helpers

Move resolveProvidersToFetch, buildProviderLabelMap, aggregateSessionEntries,
sumFilteredAlreadyImportedCount, collectErroredProviderLabels, getSessionTitle,
getPromptPreview, and computeEmptyState out of import-session-sheet.tsx into a
colocated import-session-sheet.pure.ts. Add 28 pure unit tests that exercise
provider resolution, dedupe/sort, error label fallback, title fallback, and
the empty-state state machine directly — no JSDOM, no vi.mock.

The existing 859-line JSDOM import-session-sheet.test.tsx stays for now; next
ticks can replace its status-message and empty-state cases with the pure
coverage.

* Make subagents track a ports-and-adapters unit test

Move the row-presentation data builder from track.tsx into the colocated
pure track-presentation.ts and cover statusBucket, titleState, and label
in plain node tests. Delete the 235-line JSDOM track.test.tsx with its
eight vi.mocks and createRoot harness — the remaining behaviours (empty
returns null, useState toggle, onPress wiring) are React idioms covered
by the framework, not domain logic.

* Make isolated-bottom-sheet-modal a ports-and-adapters unit test

* Stop UI from leaking through subagents barrel

The subagents/index.ts barrel re-exported SubagentsTrack (the React
component) alongside pure logic like selectSubagentsForParent. Any pure
consumer of the barrel transitively pulled in lucide-react-native and
react-native-unistyles, forcing tests that only touch pure logic to
declare cosmetic vi.mock blocks for icons, theme, tooltip, and provider
icons just to get the import graph to load.

Drop SubagentsTrack from the barrel. The one external caller
(panels/agent-panel.tsx) deep-imports from @/subagents/track instead.
The workspace-subagents-integration test loses 56 lines of pretend-UI
mocks; only the AsyncStorage mock remains because workspace-layout-store
uses it directly through persist middleware.

* Collapse use-hover-safe-zone.pure.ts into hover-safe-zone-tracker.ts

The `.pure.ts` suffix mimics tooling-resolved variants (`.web.ts`,
`.native.ts`, `.test.ts`) without being one. The file exports a single
tracker; rename it after its role. Test sits next to its subject by name.

* Promote use-settings to a directory module

* Promote sidebar-collapsed-sections-store to a directory module

* Promote navigate-to-agent to a directory module

The `.pure.ts` filename suffix mimics tooling-resolved variants
(`.web.ts`, `.native.ts`, `.test.ts`) without being one. Move the pure
resolver and its wired wrapper into `utils/navigate-to-agent/`, where
the directory carries the domain and `resolve.ts` names the role. The
pure function is renamed `resolveNavigateToAgent` so it no longer
collides with the wrapper's exported `navigateToAgent`.

* Collapse use-client-activity.pure.ts into client-activity-tracker.ts

* Collapse open-project.pure.ts into open-project.ts

* Promote use-changes-preferences to a directory module

Replaces the .pure.ts / .test-utils.ts double-suffix with a
directory home that mirrors hooks/use-settings/:

  hooks/use-changes-preferences/
    index.ts          ← React hook; wires AsyncStorage
    storage.ts        ← pure load/save
    storage.test.ts   ← tests against storage
    fakes.ts          ← in-memory KeyValueStorage adapter

External callers continue importing @/hooks/use-changes-preferences
unchanged (resolves to index.ts).

* Promote draft-store to a directory module

* Collapse use-archive-agent.pure.ts into use-archive-agent.ts

* Rename import-session-sheet.pure.ts to import-session-sheet-view-model.ts

* Rename image-attachment-picker.native.pure.ts to picked-image-normalizer.ts

* Inline use-agent-initialization.pure.ts into use-agent-initialization.ts

* Rename use-sidebar-workspaces-list.pure.ts to sidebar-workspaces-view-model.ts

* Rename review/store.pure.ts to review/state.ts

* Promote browser-store to a directory module

* Promote navigation-active-workspace-store to a directory module

* Promote panel-store to a directory module

* Promote session-store-hooks to a directory module

* Promote workspace-tabs-store to a directory module

* Rename use-archive-subagent.pure.ts to archive-subagent.ts

* Rename sidebar-workspace-archive-redirect.pure.ts to workspace-archive-redirect.ts

* Rename workspace-navigation.pure.ts to prepare-workspace-tab.ts
2026-05-28 12:34:31 +08:00
paseo-ai[bot]
5cede0a7bb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-27 18:01:44 +00:00
Mohamed Boudra
53c14d9855 Extract client SDK package (#1052)
* Extract client SDK package

* Polish SDK client identity defaults

* Build client before dependent CI jobs

* Restore daemon client server export

* Extract protocol and client SDK packages

* Fix provider override schema validation

* Fix app test daemon client imports

* Simplify workspace build targets

* Fix CLI test server build bootstrap

* Run SDK package tests in CI

* Fix rebase package split drift

* Restore lockfile registry metadata

* Update SDK config test for prompt default

* Move terminal stream router test to client package

* Fix rebase drift for protocol imports

* Fix SDK agent capability fixture

* Restore legacy server client exports

* Fix server export compatibility test

* Advertise custom mode icon client capability

* Remove server daemon-client exports

* Format rebased mode control import

* Fix rebase drift for protocol imports

Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
2026-05-28 01:58:18 +08:00
Mohamed Boudra
0ea41378a4 Fix provider binary diagnostics for command overrides (#1191)
* fix: align provider binary diagnostics

* Fix provider launch tests on Windows

* Separate provider launch availability checks
2026-05-28 00:54:43 +08:00
Mohamed Boudra
00759e7994 Fix provider-selection test for synthetic default row
The selector rework now synthesizes a "Default" row for ready enabled
providers without explicit models, so they share the same `kind: "models"`
shape as providers with real models. Updated the stale assertion that
still expected the old `providerDefault` kind.
2026-05-27 23:14:33 +07:00
Mohamed Boudra
e3eb333ddc Render opencode Edit tool calls as diffs
opencode's Edit tool sends camelCase input keys (filePath, oldString,
newString) and a plain-string success ack on output. The shared edit
schemas expect snake_case input and an object output, so calls fell
through to the generic "unknown" renderer (raw JSON Input/Output).

Normalize both at the opencode boundary so the quirk does not leak into
the cross-provider schema.
2026-05-27 22:43:08 +07:00
Mohamed Boudra
a025f17a73 Fix workspace git service tests for facts optimization
Tests stubbed the old dep set and asserted on call signatures
that no longer match after the getCheckoutSnapshotFacts work was
threaded through refresh/observation/getPullRequestStatus.
2026-05-27 22:29:59 +07:00
Mohamed Boudra
a4cb7431d8 Rework provider selector and settings UX
Surface all enabled providers in the model selector with inline loading
and error states instead of silently hiding non-ready ones. Tap any row
to drill in; loading and error states show in the drill-down body, with
a Retry button on error. Providers with no models render a synthetic
"Default" row so every provider's drill-down has symmetric structure.

Redesign the per-provider settings sheet: search lives in the header,
discovered and custom models share one scrolling list, and the footer
hosts action buttons. Diagnostic and "Add model" each open as their own
sub-sheet, so neither expands inline or shifts the surrounding layout.

Snapshot refresh now keeps cached data visible: setQueryData replaces
the cache atomically and sibling-scope caches are invalidated (not
removed), so refreshing a provider no longer blanks its row. Search
inputs across the app are uncontrolled — onChangeText drives parent
state, and resetKey remounts the field when an explicit reset is needed
— to avoid RN's controlled-input flicker.

Adds a dev-only mock-slow provider whose discovery never resolves, so
the 30s snapshot timeout produces a real error and lets loading and
timeout-error UI be exercised end to end.
2026-05-27 22:00:34 +07:00
Mohamed Boudra
5696cdb455 refactor(server): integrate session MCP command stack (#893)
* Extract permission response command

* Extract create agent command

* Extract agent lifecycle commands

* refactor(server): extract worktree archive command

* refactor(server): share worktree create list commands

* test(server): update close items lifecycle fakes

* Fix MCP command stack CI regressions

* Restore MCP update_agent no-op success semantics

After the rebase, MCP `update_agent` was returning `success: false` for empty/no-op calls (no name, no labels, no settings). Origin/main returned `success: true` unconditionally. The audit flagged the change as out of scope for this stack — restore the old behavior at the MCP boundary.

Session WS path keeps its accepted/rejected semantics (it surfaces an error when nothing was provided so the client can prompt the user).

* Fix agent metadata delegation and mode persistence

* Update MCP update_agent test for metadata delegation

* Fix stored agent metadata timestamps
2026-05-27 22:43:03 +08:00
Mohamed Boudra
5a56835db3 Add startup diagnostics reports 2026-05-27 20:59:06 +07:00
Mohamed Boudra
698d549983 Title-case agent landing page meta titles 2026-05-27 20:52:33 +07:00
Mohamed Boudra
fa1b3e88f0 Reuse git facts for workspace observation 2026-05-27 20:33:21 +07:00
Mohamed Boudra
5d8dc800fc Reduce startup git work for workspace snapshots 2026-05-27 20:18:49 +07:00
Mohamed Boudra
dbfd42da46 Remove Reddit icon from site header 2026-05-27 19:10:55 +07:00
Mohamed Boudra
2894917a1c Add Reddit link to website and README 2026-05-27 19:01:17 +07:00
Mohamed Boudra
9c2d47ab34 Deploy website for docs changes 2026-05-27 16:17:10 +07:00
Mohamed Boudra
d787aefa4c Fix OpenCode MCP injection on wildcard binds
Wildcard listen addresses are bind targets, not client endpoints. Inject loopback for agent MCP URLs and surface OpenCode MCP add failures returned in data payloads.
2026-05-27 16:01:06 +07:00
Mohamed Boudra
8307a0ca6f docs: add Codex custom OpenAI-compatible endpoint example
Promote the brief Codex env-var note into a dedicated section next to
the Z.AI and Qwen examples, showing exactly which env vars Paseo wires
into Codex's model_providers thread config and how base_url, wire_api,
env_key, and requires_openai_auth are derived.
2026-05-27 15:57:34 +07:00
Mohamed Boudra
7aa49b2905 Add SEO landing pages 2026-05-27 13:40:49 +07:00
Mohamed Boudra
d594bce153 Rank slash command autocomplete matches 2026-05-27 13:40:49 +07:00
Mohamed Boudra
a630986d06 Align user message footer controls 2026-05-27 13:40:49 +07:00
paseo-ai[bot]
724a499413 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-27 04:59:03 +00:00
Li Mu Zhi
8aa1530be1 fix(app): native iOS text selection in assistant messages via UITextView
Fixes #238
Fixes #648
Refs #21
2026-05-27 04:55:46 +00:00
Mohamed Boudra
1908ab8765 Move context ring to footer right edge on compact 2026-05-27 11:13:23 +07:00
Mohamed Boudra
5ad6cff039 Drop color treatment from agent mode controls 2026-05-27 10:56:31 +07:00
Mohamed Boudra
8a463c55c8 Update daemon hello capability test 2026-05-27 09:59:58 +07:00
Mohamed Boudra
aecb300073 Stabilize OpenCode provider unit tests 2026-05-27 09:51:40 +07:00
Mohamed Boudra
4911b627f2 Treat agent mode icons as open strings
Old clients pinned AgentModeIcon to a closed enum and rendered
undefined for unknown values, crashing the route in production
Hermes with no stack trace. Loosen the type to a plain string,
let the client skip rendering when an icon is unregistered, and
add the customModeIcons capability so the daemon downgrades
non-legacy icons to ShieldCheck for clients that pre-date the
open-string contract.
2026-05-27 09:30:50 +07:00
Mohamed Boudra
a5b82d2a3b Relax MCP tool output validation 2026-05-26 23:22:09 +07:00
Mohamed Boudra
09bf981f13 Reshape stream head/tail spacing 2026-05-26 23:01:42 +07:00
Mohamed Boudra
3cf92ad6c5 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-26 22:35:38 +07:00
Mohamed Boudra
153fa42a95 Tighten markdown list spacing 2026-05-26 22:27:18 +07:00
Mohamed Boudra
6d205f8853 Add OpenCode auto accept feature 2026-05-26 21:51:04 +07:00
paseo-ai[bot]
5468089ac0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 13:54:15 +00:00
Mohamed Boudra
0ab41fbd9a chore(release): cut 0.1.83 2026-05-26 20:50:25 +07:00
Mohamed Boudra
ed6caa11c0 Update changelog for 0.1.83 2026-05-26 20:49:15 +07:00
Mohamed Boudra
74c8942a28 Confirm create-agent start before returning
Wait for the initial background turn to actually start before create returns a snapshot, without waiting for completion.

Freshly created agents now surface start failures as create failures, and both the WS/client and MCP surfaces reflect the shared create-agent lifecycle helper.
2026-05-26 20:38:27 +07:00
Mohamed Boudra
3eb1ba7d73 Narrow MCP schedule cadence handling to blank values 2026-05-26 20:15:12 +07:00
Mohamed Boudra
1d2c8b1648 Fix MCP schedule cadence placeholder handling
Treat undefined, blank, whitespace-only, and /__omit__ cadence values as absent at the MCP boundary for schedule create and update.

That keeps placeholder inputs from tripping cadence validation while preserving the real invalid cases for conflicting or missing cadence values.
2026-05-26 20:15:11 +07:00
Mohamed Boudra
88914ccba6 Fix draft mode chip for non-thinking models
Keep the desktop controls row alive when desktop extras exist, so draft mode chips still render even when a model has no thinking options.\n\nRestore the replacement draft e2e assertion for the mode chip now that it renders again.
2026-05-26 20:15:11 +07:00
paseo-ai[bot]
f79101a1f0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 10:13:47 +00:00
701 changed files with 26865 additions and 19773 deletions

View File

@@ -69,18 +69,18 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Typecheck all packages
run: npm run typecheck
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
strategy:
fail-fast: false
@@ -107,11 +107,8 @@ jobs:
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependencies
run: npm run build:server-deps
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
@@ -137,14 +134,8 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
@@ -165,12 +156,37 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
sdk-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build client dependencies
run: npm run build:client
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
runs-on: ubuntu-latest
steps:
@@ -187,14 +203,11 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Build server stack
run: npm run build:server
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
@@ -228,7 +241,7 @@ jobs:
run: npm ci
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
run: npm run build:relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
@@ -254,9 +267,6 @@ jobs:
- name: Install agent CLIs for provider tests
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
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:

View File

@@ -28,8 +28,8 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build app dependencies
run: npm run build:app-deps
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- "CHANGELOG.md"
- "public-docs/**"
- "packages/website/**"
- "package.json"
- "package-lock.json"

View File

@@ -183,6 +183,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-macos-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -273,6 +281,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-linux
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
@@ -360,6 +376,14 @@ jobs:
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
uses: actions/upload-artifact@v4
with:
name: desktop-windows
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
retention-days: 7
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4

View File

@@ -10,6 +10,8 @@ on:
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"

View File

@@ -1,5 +1,54 @@
# Changelog
## 0.1.85 - 2026-05-29
### Added
- **Opus 4.8 in the Claude model picker**, with a 1M-context variant
### Improved
- Archiving a worktree now keeps its agents under the archived list instead of removing them
- Archiving an agent cleans up any schedules targeting it
## 0.1.84 - 2026-05-28
### Added
- **Auto-accept tool calls for OpenCode agents**
### Improved
- Copy an OpenCode resume command to continue the session outside Paseo
- Model selector lists every enabled provider, with a Retry button when one fails to load
- Provider settings are easier to search and manage
- Other agents connecting to Paseo via MCP see the same providers, models, and modes as the app ([#1198](https://github.com/getpaseo/paseo/pull/1198))
- OpenCode Edit tool calls render as inline diffs
- Typing a slash command shows the best match first
- Daemon starts faster on workspaces with many git folders
- Markdown lists have tighter spacing
- Less jank when streaming agent responses
- User message footer controls align with the rest of the chat
- Agent mode controls use a cleaner monochrome treatment
- Compact layouts move the context ring to the footer right edge
### Fixed
- Allow selecting text in the chat on mobile ([#1153](https://github.com/getpaseo/paseo/pull/1153) by [@muzhi1991](https://github.com/muzhi1991))
- Submitting a Pi question no longer looks like a second prompt opened ([#1188](https://github.com/getpaseo/paseo/pull/1188) by [@yuruiz](https://github.com/yuruiz))
- Daemon memory leak from unbounded workspace git caches ([#1200](https://github.com/getpaseo/paseo/pull/1200))
- Provider diagnostics include the command override binary path ([#1191](https://github.com/getpaseo/paseo/pull/1191))
- OpenCode MCP servers connect correctly when the daemon binds to wildcard addresses
- Tool calls from MCP servers that return non-spec output no longer fail validation
## 0.1.83 - 2026-05-26
### Fixed
- Creating an agent via MCP now waits for it to actually start, so failures surface as a clear create error
- Scheduling an agent via MCP no longer rejects blank cadence placeholders
- Draft messages show the agent mode chip again on models without thinking options
## 0.1.82 - 2026-05-26
### Added

View File

@@ -71,8 +71,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
- `npm run build:client` — rebuild protocol and client declarations.
- `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:

View File

@@ -17,6 +17,9 @@
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
@@ -131,8 +134,8 @@ npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# build the server stack
npm run build:server
# repo-wide checks
npm run typecheck

View File

@@ -68,9 +68,20 @@ All paths are under `packages/server/src/`.
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
### `packages/protocol` — Wire schemas and shared protocol types
The source of truth for WebSocket messages, binary frame codecs, endpoint parsing,
agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server.
### `packages/client` — Daemon client library and SDK facade
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
### `packages/app` — Mobile + web client (Expo)
@@ -126,7 +137,7 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`.
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
**Handshake:**

View File

@@ -24,6 +24,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
- [Extending a built-in provider](#extending-a-built-in-provider)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
- [Custom binary for a provider](#custom-binary-for-a-provider)
- [Disabling a provider](#disabling-a-provider)
@@ -59,29 +60,7 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
---
@@ -206,6 +185,62 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
---
## Codex with a custom OpenAI-compatible endpoint
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
### Setup
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"description": "Codex via custom OpenAI-compatible endpoint",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
### What Paseo wires up
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml
model_provider = "my-codex"
[model_providers.my-codex]
name = "My Codex"
base_url = "https://custom-relay.example.com/v1"
wire_api = "responses"
env_key = "OPENAI_API_KEY"
requires_openai_auth = false
```
- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
- `wire_api` — always `"responses"` (OpenAI Responses API protocol).
- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
### Notes
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.

View File

@@ -171,6 +171,8 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
---
## 3. Schedule

View File

@@ -109,23 +109,28 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
}
```
## Build sync gotchas
## Built workspace packages
The daemon and CLI consume sibling workspaces from compiled `dist/` output, not `src/`. When you change a workspace that something else imports, rebuild the producer first or the consumer will speak a stale protocol and fail with handshake warnings, timeouts, or stale type errors.
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
```bash
npm run build:daemon
npm run build:client # protocol -> client
npm run build:server-deps # highlight -> relay -> protocol -> client
npm run build:server # server-deps -> server -> cli
npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-audio
```
This rebuilds, in order, `@getpaseo/highlight``@getpaseo/relay``@getpaseo/server``@getpaseo/cli`. Use it whenever you have changed any of those four and need clean cross-package types or runtime behavior.
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
For tighter loops, you can rebuild a single workspace:
- Changed `packages/relay/src/*`: `npm run build --workspace=@getpaseo/relay` (server imports `@getpaseo/relay` from `dist/*`).
- Changed `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types: `npm run build --workspace=@getpaseo/server` (CLI imports `@getpaseo/server` via package exports resolving to `dist/*`).
- Changed `packages/highlight/src/*`: `npm run build --workspace=@getpaseo/highlight` (server depends on it).
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
## CLI reference

View File

@@ -0,0 +1,211 @@
# Git Snapshot Startup Reshaping - 2026-05-27
## What changed
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
```text
daemon startup / workspace subscription
-> WorkspaceGitService.refreshSnapshot(cwd)
-> getCheckoutSnapshotFacts(cwd)
-> getCheckoutStatus(cwd, { facts })
-> getCheckoutShortstat(cwd, { facts })
-> getPullRequestStatus(cwd, github, ..., { facts })
-> WorkspaceGitRuntimeSnapshot
-> session workspace descriptor githubRuntime.pullRequest
-> app useSidebarWorkspacesList()
-> SidebarWorkspaceEntry.prHint
-> Sidebar row badge + hover card checks
```
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
## Shared Git Facts
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
- worktree root: `rev-parse --show-toplevel`
- current branch: `rev-parse --abbrev-ref HEAD`
- origin remote URL
- Paseo worktree ownership and stored base ref
- resolved base ref and best comparison base
- main repo root
- branch remote/merge config
- tracked origin branch
- pull request lookup target for fork/PR worktrees
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
## Current Data Flow
```text
Workspace subscription / fetch_workspaces
-> session workspace registry
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
-> refresh queue/throttle/dedupe per normalized cwd
-> refreshGitSnapshot()
-> getCheckoutSnapshotFacts()
-> getCheckoutStatus({ facts })
-> getCheckoutShortstat({ facts })
-> refreshGitHubSnapshot()
-> getPullRequestStatus({ facts })
-> cached WorkspaceGitRuntimeSnapshot
-> WorkspaceDescriptorPayload.gitRuntime
-> WorkspaceDescriptorPayload.githubRuntime
-> app session store
-> useSidebarWorkspacesList()
-> diffStat from descriptor
-> prHint from descriptor.githubRuntime.pullRequest
```
## Startup Benchmark
Added deterministic real-home benchmark:
`packages/server/scripts/benchmark-startup-git-real-home.ts`
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
The frozen home used for the comparison contained 22 workspaces.
### Before/After
| run | code shape | client shape | git commands | failures | elapsed |
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
### Baseline: before change + legacy PR fanout
```json
{
"scenario": "legacyPrFanout",
"workspaceCount": 22,
"elapsedMs": 39039,
"git": {
"total": 529,
"failed": 20,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
{ "key": "rev-parse --git-common-dir", "count": 58 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
{ "key": "rev-parse --git-dir", "count": 36 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
{ "key": "config --get remote.origin.url", "count": 32 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 14 },
{ "key": "status --porcelain", "count": 14 }
]
},
"process": {
"cpuUserMs": 2009,
"cpuSystemMs": 2428,
"rssDeltaMb": -1.5,
"heapUsedDeltaMb": 16.9
}
}
```
### After: after change + snapshot-only sidebar
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31273,
"git": {
"total": 372,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "config --get remote.origin.url", "count": 35 },
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
{ "key": "rev-parse --git-common-dir", "count": 31 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "status --porcelain", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 17 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
{ "key": "rev-list --count main..origin/main", "count": 7 }
]
},
"process": {
"cpuUserMs": 1871,
"cpuSystemMs": 2152,
"rssDeltaMb": 4.4,
"heapUsedDeltaMb": 8.8
}
}
```
### After 2: shared service-level facts
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31334,
"git": {
"total": 308,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
{ "key": "rev-parse --git-common-dir", "count": 26 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "status --porcelain", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "config --get remote.origin.url", "count": 13 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
{ "key": "rev-parse --show-toplevel", "count": 13 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
{ "key": "fetch origin --prune", "count": 5 }
]
},
"process": {
"cpuUserMs": 1817,
"cpuSystemMs": 1869,
"rssDeltaMb": 16.7,
"heapUsedDeltaMb": 10.4
}
}
```
## Snapshot Equivalence Guard
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
- `rev-parse --show-toplevel`
- `rev-parse --abbrev-ref HEAD`
Test:
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
## Remaining Waste Visible In Baseline
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
- base ref existence checks still repeat as `show-ref` probes.
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
- repo common-dir lookup is lower, but still above the apparent git workspace count.
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.

View File

@@ -0,0 +1,389 @@
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
## Answer
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
- `/Users/moboudra`
- `/Users/moboudra/dev/paseo`
- `/Users/moboudra/dev/blankpage/editor`
3. Each OpenCode snapshot runs two OpenCode SDK calls:
- `GET /provider?directory=...` through `client.provider.list()`
- `GET /agent?directory=...` through `client.app.agents()`
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
So the root cause is:
```text
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
```
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
## Follow-up: Normal Copied-Home Startup Check
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
```text
fetchWorkspaces
fetchAgents
getProvidersSnapshot(home scope)
getProvidersSnapshot(first workspace scope)
```
That run did not reproduce the 30s OpenCode timeout.
```text
home scope:
OpenCode ready at ~8s
availability: 1.6s
fetch total: 5.2s
first workspace scope:
OpenCode ready at ~26s
availability: 2.0s
fetch total: 15.4s
```
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
## Reproduction Used
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
Relevant scripts:
- `scripts/dev.sh`
- `scripts/dev-daemon.sh`
- `scripts/dev-home.sh`
`dev-home.sh` only seeds this metadata into the dev home:
```text
agents/**/*.json
projects/**/*.json
config.json
```
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
I ran a separate daemon, not the main daemon:
```text
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
PASEO_LISTEN=127.0.0.1:51116
PASEO_LOG_LEVEL=trace
```
Startup facts:
```text
18:13:39.552 Agent storage initialized: 712ms
18:13:39.559 Workspace registries bootstrapped: 719ms
18:13:39.961 Agent registry loaded: 4851 records
18:13:39.972 Server listening: http://127.0.0.1:51116
```
The probe then connected four client sessions and requested:
- workspaces
- active agents
- provider snapshots for home, paseo, and blankpage/editor
Client-visible result:
```text
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
OpenCode app.agents timed out after 10s
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
Timed out refreshing OpenCode after 30000ms
18:14:41.688 /Users/moboudra opencode error:
Timed out refreshing OpenCode after 30000ms
```
## Exact OpenCode Timeline
OpenCode snapshot requests began at `18:14:10`.
Availability checks:
```text
18:14:10.780 opencode availability start for /Users/moboudra
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
18:14:11.363 paseo availability complete: 576ms
18:14:11.376 home availability complete: 597ms
18:14:11.391 blankpage availability complete: 591ms
```
OpenCode server acquisition:
```text
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
18:14:19.926 OpenCode server listening after 8562ms
```
Six SDK calls were then issued:
```text
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
18:14:19.931 GET /provider directory=/Users/moboudra
18:14:19.931 GET /agent directory=/Users/moboudra
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
```
Why six:
| Cwd | Why that scope exists | Model call | Mode call |
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
Headers arrived before the 30s timeout:
| Call | Cwd | Headers after request |
| ----------- | ------------------------------- | --------------------- |
| `/provider` | `/Users/moboudra` | 6.462s |
| `/agent` | `/Users/moboudra` | 6.681s |
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
But body consumption and completion lagged:
```text
18:14:29.380 /agent home complete, total app.agents duration 9450ms
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
18:14:30.263 /agent blankpage timed out at 10s
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
18:14:41.687 paseo snapshot outer 30s timeout fires
18:14:41.688 home snapshot outer 30s timeout fires
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
```
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
## Why Settings Refresh Works
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
Results:
```text
home refresh:
total: 2165ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/paseo refresh:
total: 1675ms
status: ready
models: 409
modes: 5
/Users/moboudra/dev/blankpage/editor refresh:
total: 1794ms
status: ready
models: 409
modes: 5
```
Trace details for the manual-style refresh:
```text
OpenCode server acquisition: 708ms-1291ms
/agent completion: 433ms-592ms after request start
/provider completion: 524ms-618ms after request start
```
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
## Minimal OpenCode-Only Repros
### OpenCode Only, No Daemon, No Artificial Load
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
```text
GET /provider?directory=/Users/moboudra
GET /agent?directory=/Users/moboudra
GET /provider?directory=/Users/moboudra/dev/paseo
GET /agent?directory=/Users/moboudra/dev/paseo
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
```
Three runs:
| Run | `opencode serve` ready | All six calls complete |
| --- | ---------------------- | ---------------------- |
| 1 | 1376ms | 1295ms |
| 2 | 906ms | 1050ms |
| 3 | 939ms | 898ms |
Slowest individual call in those runs:
```text
/provider /Users/moboudra/dev/paseo: 1270ms total
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
```
So six concurrent OpenCode calls alone are not the bug.
### OpenCode Only Plus External Git Storm, No Daemon
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
Result:
```text
opencode serve ready: 15479ms
all six OpenCode calls complete: 15176ms after server ready
combined cold-start + calls: about 30655ms
```
Individual calls under the external git storm:
| Call | Cwd | Total |
| ----------- | -------------------------------------- | ------: |
| `/provider` | `/Users/moboudra` | 10684ms |
| `/agent` | `/Users/moboudra` | 10767ms |
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
## Why It Does Not Retry
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
- no existing snapshot
- missing providers
- entries still in `loading` with no active load
When refresh fails, `refreshProvider()` stores:
```text
status: "error"
error: "Timed out refreshing OpenCode after 30000ms"
```
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
```text
refreshSettingsSnapshot()
clearCachedProviders()
resetSnapshotToLoading()
refreshProviders(... force: true)
```
That is why you have to force a manual refresh.
## Git Work During The Repro
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
Total git commands in the dev-style copied-home daemon log:
```text
632 spawned
632 closed
```
Top cwd counts:
| Count | Cwd |
| ----: | ------------------------------------------------------------------------------------- |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
| 24 | `/Users/moboudra/dev/paseo` |
| 24 | `/Users/moboudra/dev/blankpage/editor` |
| 24 | `/Users/moboudra/dev/faro/main` |
| 24 | `/Users/moboudra/dev/konbert/web` |
| 24 | `/Users/moboudra/dev/paseo-cloud` |
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
```text
142 git command spawns
150 git command closes
```
The main repeated command shapes were:
```text
76 git rev-parse --show-toplevel
72 git status --porcelain
72 git show-ref --verify --quiet refs/remotes/origin/main
72 git show-ref --verify --quiet refs/heads/main
16 git config --get branch.main.remote
16 git config --get branch.main.merge
16 git rev-list --count main..origin/main
16 git rev-list --count origin/main..main
```
## Original `log.txt` Alignment
The original startup showed the same home and paseo outer timeout shape:
```text
16:04:22.466 /Users/moboudra/dev/paseo:
Timed out refreshing OpenCode after 30000ms
16:04:22.482 /Users/moboudra:
Timed out refreshing OpenCode after 30000ms
```
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
## Files Instrumented For Diagnosis
Temporary trace instrumentation was added to:
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
The instrumentation is behavior-neutral and only emits trace logs.

View File

@@ -0,0 +1,381 @@
# Daemon Startup Sequence Analysis - 2026-05-27
Source log: `log.txt` at repository root.
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
This report is descriptive only. It does not propose optimizations.
## Executive Summary
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
- Worker start: `16:03:46.678`, line 1.
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
- First client hello: `16:03:50.285`, line 66.
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
The startup shape is therefore:
- Daemon listen readiness: about `0.6s`.
- Client reconnect plus workspace/app/provider hydration: about `45s`.
- No git commands after workspace registry reconciliation in this slice.
## Method
I parsed structured trace lines from `log.txt`, especially:
- `Git command closed`
- `agent.session.inbound`
- `agent.session.outbound`
- `ws_slow_request`
- provider snapshot warnings
- provider resume events
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
Relevant code paths checked:
- `packages/server/src/server/session.ts`
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
- `packages/server/src/server/workspace-git-service.ts`
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
- `packages/app/src/contexts/session-context.tsx`
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
## Startup Timeline
| time | line | event |
| -------------- | ---: | ------------------------------------------------------------------ |
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
| `16:03:47.683` | 4 | worker spawned |
| `16:03:47.684` | 6 | daemon keypair loaded |
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
| `16:03:50.274` | 60 | first websocket awaiting hello |
| `16:03:50.285` | 66 | first client connected via hello |
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
| `16:04:24.306` | 1214 | OpenCode server connected event |
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
| `16:04:33.666` | 1777 | workspace registries reconciled |
| `16:04:34.197` | 1783 | OpenCode heartbeat |
| `16:04:44.200` | 1789 | OpenCode heartbeat |
## Git Command Totals
Total git commands in the sliced startup: `444`.
| phase | commands | failures | summed process time |
| ------------------------------- | -------: | -------: | ------------------: |
| daemon bootstrap before listen | 13 | 4 | 445ms |
| post-listen before first client | 1 | 0 | 2020ms |
| client reconnect + reconcile | 430 | 71 | 120813ms |
| after reconcile | 0 | 0 | 0ms |
| total | 444 | 75 | 123278ms |
Summed process time is not wall-clock time. Many commands overlap.
## Git Command Categories
| category | commands | failures | summed process time | max duration |
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
What those categories mean in the app:
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
- Dirty status: show dirty/clean workspace state.
- Upstream config and ahead/behind: show branch tracking and sync state.
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
## Per-Workspace Git Work
Columns:
- `phase`: `pre/warm/reconnect/after`
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
- `total_ms`: summed process time for that workspace
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
## Git Failure Shape
There were 75 nonzero git exits.
Most failures were not timeouts. They were expected probe failures:
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
## Inbound Client Work
Inbound session messages during the startup window:
| request | count |
| --------------------------------- | ----: |
| `client_heartbeat` | 19 |
| `checkout_pr_status_request` | 18 |
| `fetch_agents_request` | 11 |
| `fetch_workspaces_request` | 9 |
| `get_providers_snapshot_request` | 9 |
| `project_icon_request` | 9 |
| `fetch_agent_timeline_request` | 7 |
| `clear_agent_attention` | 6 |
| `list_terminals_request` | 5 |
| `subscribe_terminals_request` | 5 |
| `list_available_editors_request` | 2 |
| `subscribe_checkout_diff_request` | 2 |
| `checkout_status_request` | 1 |
| `fetch_agent_request` | 1 |
| `file_explorer_request` | 1 |
| `read_project_config_request` | 1 |
| `workspace_setup_status_request` | 1 |
Inbound by client:
| client | count | top work |
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
## Outbound Client Work
Outbound session messages during the startup window:
| message | count |
| ---------------------------------- | ----: |
| `providers_snapshot_update` | 129 |
| `workspace_update` | 81 |
| `checkout_status_update` | 76 |
| `agent_update` | 47 |
| `checkout_pr_status_response` | 18 |
| `fetch_agents_response` | 11 |
| `fetch_workspaces_response` | 9 |
| `get_providers_snapshot_response` | 9 |
| `project_icon_response` | 9 |
| `fetch_agent_timeline_response` | 7 |
| `list_terminals_response` | 5 |
| `terminals_changed` | 5 |
| `list_available_editors_response` | 2 |
| `subscribe_checkout_diff_response` | 2 |
| `checkout_status_response` | 1 |
| `fetch_agent_response` | 1 |
| `file_explorer_response` | 1 |
| `read_project_config_response` | 1 |
| `workspace_setup_status_response` | 1 |
Provider snapshot updates were large and repeated:
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
## Slow Requests
Slow requests logged during startup:
| time | request | duration | client | line |
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
## Provider Findings
### OpenCode
OpenCode provider snapshot refresh had two timeouts:
| time | line | cwd | error |
| -------------- | ---: | --------------------------- | --------------------------------------------- |
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
These are provider snapshot failures, not OpenCode agent resume failures.
The persisted OpenCode agent did resume:
| time | line | event |
| -------------- | ---: | ----------------------------------------------------- |
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
| `16:04:24.306` | 1214 | raw event `server.connected` |
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
OpenCode-related git:
- `~/dev/opencode` had 22 git commands.
- Four failed.
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
- Those failures are git state/probe failures, not OpenCode provider process failures.
### Codex
Codex provider startup observations:
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
### Claude
Claude agents resume successfully:
| time | line | client | agent |
| -------------- | ---: | -------- | -------------------------------------- |
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
## What Work The App Asked For
The startup work visible in the app/server protocol is:
- Workspace list/sidebar hydration:
- `fetch_workspaces_request`, 9 total.
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
- On the server this triggers workspace git observer sync and workspace update flushing.
- Agent list and agent detail hydration:
- `fetch_agents_request`, 11 total.
- `fetch_agent_request`, 1 total.
- `fetch_agent_timeline_request`, 7 total.
- Timeline requests are among the slowest requests in this slice.
- Checkout/PR status UI:
- `checkout_pr_status_request`, 18 total, all Electron.
- `checkout_status_request`, 1 total.
- `subscribe_checkout_diff_request`, 2 total.
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
- Provider/model/mode UI:
- `get_providers_snapshot_request`, 9 total.
- `providers_snapshot_update`, 129 outbound updates.
- OpenCode provider snapshot refresh times out twice during this flow.
- Workspace chrome:
- `project_icon_request`, 9 total.
- `file_explorer_request`, 1 total.
- Terminal panel:
- `list_terminals_request`, 5 total.
- `subscribe_terminals_request`, 5 total.
- `terminals_changed`, 5 outbound updates.
- Attention state:
- `clear_agent_attention`, 6 total.
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
## Concrete Waste-Looking Work, Without Optimizing Yet
The log shows repeated work in these exact forms:
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
- Several workspaces get repeated full checkout snapshot patterns:
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.

View File

@@ -3,24 +3,24 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/server/src/shared/messages.ts:1936`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2113`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/server/src/shared/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
@@ -33,4 +33,4 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).

View File

@@ -24,7 +24,7 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.

View File

@@ -2,6 +2,29 @@
All workspaces share one version and release together.
## Two steps
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
Rules that apply to both steps:
- Last-minute changes always need approval. Every time.
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
## Two paths
There are two supported ways to ship from `main`:

View File

@@ -113,6 +113,8 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
### Test setup
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
@@ -155,3 +157,12 @@ If code isn't testable, refactor it. Signs:
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
## Two test categories, no others
Every test in this repo lives in exactly one of these shapes:
1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.

View File

@@ -4,6 +4,7 @@ pre-commit:
- name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
exclude:
- "package-lock.json"
- "**/package-lock.json"
run: npm run format:check:files -- {staged_files}
- name: lint

View File

@@ -73,10 +73,10 @@ buildNpmPackage rec {
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# Server workspaces (highlight + relay + protocol + client + server + cli)
npm run build:server
# App workspace deps not covered by build:daemon
# App workspace deps not covered by build:server
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer

View File

@@ -1 +1 @@
sha256-s1HwJgifRYWfnIVit2JVrJmFSyu8lL8TpX67I/33dQQ=
sha256-25V9uu0hSecGCDixaJwQ9wu6Mj0cfG6uXNYjMZIZ58U=

View File

@@ -79,8 +79,8 @@ buildNpmPackage rec {
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
# Build all server packages in dependency order (defined in package.json)
npm run build:server
runHook postBuild
'';

764
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.82",
"version": "0.1.85",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -24,6 +24,8 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/protocol",
"packages/client",
"packages/server",
"packages/app",
"packages/relay",
@@ -34,16 +36,24 @@
"scripts": {
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "oxfmt .",
"format:files": "oxfmt",
@@ -63,7 +73,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
@@ -77,9 +87,9 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",

View File

@@ -126,10 +126,7 @@ async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<v
await expect(
page.getByRole("button", { name: "Select model (Ten second stream)" }),
).toBeVisible();
// TODO(boudra): the replacement draft's mode picker stopped rendering after
// the composer refactor — the model carries over but modes aren't surfaced
// in the draft's provider snapshot. Restore this assertion once the draft
// mode picker is fixed.
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
}
async function createAgentFromReplacementDraft(page: Page): Promise<void> {

View File

@@ -321,7 +321,7 @@ function ensureRelayBuildArtifact(repoRoot: string): void {
}
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
execSync("npm run build:relay", {
cwd: repoRoot,
stdio: "inherit",
});

View File

@@ -1,7 +1,6 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
@@ -91,21 +90,11 @@ interface DaemonClientConfig {
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: DaemonClientConfig) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const DaemonClient = await loadDaemonClientConstructor<
DaemonClientConfig,
DaemonClientInstance
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),

View File

@@ -1,8 +1,7 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
@@ -83,21 +82,11 @@ interface ArchiveTabDaemonClientConfig {
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const DaemonClient = await loadDaemonClientConstructor<
ArchiveTabDaemonClientConfig,
ArchiveTabDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),

View File

@@ -0,0 +1,15 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
new (config: ClientConfig) => ClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/client/dist/daemon-client.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ClientConfig) => ClientInstance;
};
return mod.DaemonClient;
}

View File

@@ -1,14 +1,13 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
type NewWorkspaceDaemonClient = Pick<
ServerDaemonClient,
InternalDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "close"
@@ -48,19 +47,6 @@ function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
};
return mod.DaemonClient;
}
function requireWorkspace(payload: OpenProjectPayload) {
if (payload.error) {
throw new Error(payload.error);
@@ -83,7 +69,10 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const DaemonClient = await loadDaemonClientConstructor<
NewWorkspaceDaemonClientConfig,
NewWorkspaceDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),

View File

@@ -27,6 +27,7 @@ interface ProviderLaunchConfig {
model?: string;
thinkingOptionId?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
const SEND_TIMEOUT_MS = 240_000;
@@ -52,7 +53,12 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
modeId: "full-access",
};
case "opencode":
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
return {
provider,
model: "opencode/big-pickle",
modeId: "build",
featureValues: { auto_accept: true },
};
case "pi":
return {
provider,

View File

@@ -1,8 +1,8 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
@@ -81,21 +81,11 @@ interface TerminalPerfDaemonClientConfig {
webSocketFactory?: NodeWebSocketFactory;
}
async function loadDaemonClientConstructor(): Promise<
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const DaemonClient = await loadDaemonClientConstructor<
TerminalPerfDaemonClientConfig,
TerminalPerfDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),

View File

@@ -1,13 +1,12 @@
import { realpathSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { switchWorkspaceViaSidebar } from "./workspace-ui";
import type { SessionOutboundMessage } from "@server/shared/messages";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
interface WorkspaceSetupDaemonClient {
connect(): Promise<void>;
@@ -70,31 +69,16 @@ function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${daemonPort}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
{
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient;
};
return mod.DaemonClient;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
},
WorkspaceSetupDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),

View File

@@ -15,6 +15,7 @@ OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
VIDEO_DIR="/tmp/paseo-maestro-videos"
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
@@ -55,6 +56,12 @@ require_command perl
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
if [ ! -f "$CLIENT_EXPORTS" ]; then
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
echo "Run: npm run build:client" >&2
exit 1
fi
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
@@ -96,7 +103,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,

View File

@@ -24,7 +24,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
@@ -62,9 +62,9 @@ render_flow_tree() {
done
}
if [ ! -f "$SERVER_EXPORTS" ]; then
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
echo "Run: npm run build --workspace=@getpaseo/server" >&2
if [ ! -f "$CLIENT_EXPORTS" ]; then
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
echo "Run: npm run build:client" >&2
exit 1
fi
@@ -117,7 +117,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,

View File

@@ -6,7 +6,6 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.trim()
@@ -68,11 +67,7 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
config.resolver.resolveRequest = (context, moduleName, platform) => {
const origin = context.originModulePath;
if (
origin &&
(origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) &&
moduleName.endsWith(".js")
) {
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
if (fs.existsSync(candidatePath)) {

View File

@@ -1,22 +1,22 @@
{
"name": "@getpaseo/app",
"version": "0.1.82",
"version": "0.1.85",
"private": true,
"main": "index.ts",
"scripts": {
"start": "cross-env APP_VARIANT=development expo start",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"start": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run start:expo\"",
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps && npm run build:terminal-webview",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"ios": "npm --prefix ../.. run build:client && expo run:ios",
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
"web:expo": "expo start --web",
"lint": "expo lint",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
@@ -25,16 +25,19 @@
"test:e2e:real": "playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm run build:workspace-deps && expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/client": "*",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -93,6 +96,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-uitextview": "^2.2.0",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
@@ -104,6 +108,7 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",

View File

@@ -123,6 +123,43 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
it.each(["web", "android"] as const)(
"keeps split assistant block spacing identical to unsplit history on %s",
(platform) => {
const firstBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const secondBlock = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 });
const thirdBlock = assistantMessage("turn:block:2", 4, { groupId: "turn", index: 2 });
const splitLayout = layoutFor({
platform,
agentStatus: "running",
tail: [userMessage("u1", 1), firstBlock],
head: [secondBlock, thirdBlock],
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
});
const unsplitLayout = layoutFor({
platform,
agentStatus: "running",
tail: [userMessage("u1", 1), firstBlock, secondBlock, thirdBlock],
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
});
expect(findLayoutItem(splitLayout, firstBlock.id).belowItem?.id).toBe(secondBlock.id);
expect(findLayoutItem(splitLayout, secondBlock.id).aboveItem?.id).toBe(firstBlock.id);
expect(findLayoutItem(splitLayout, firstBlock.id).assistantSpacing).toBe(
findLayoutItem(unsplitLayout, firstBlock.id).assistantSpacing,
);
expect(findLayoutItem(splitLayout, secondBlock.id).assistantSpacing).toBe(
findLayoutItem(unsplitLayout, secondBlock.id).assistantSpacing,
);
expect(findLayoutItem(splitLayout, firstBlock.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, firstBlock.id).gapBelow,
);
expect(findLayoutItem(splitLayout, secondBlock.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, secondBlock.id).gapBelow,
);
},
);
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
@@ -195,6 +232,39 @@ describe("layoutStream", () => {
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
});
it.each(["web", "android"] as const)(
"keeps split tool sequencing and gapBelow identical to unsplit history on %s",
(platform) => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);
const assistant = assistantMessage("a1", 4);
const splitLayout = layoutFor({
platform,
tail: [userMessage("u1", 1), shell],
head: [thinking, assistant],
});
const unsplitLayout = layoutFor({
platform,
tail: [userMessage("u1", 1), shell, thinking, assistant],
});
expect(findLayoutItem(splitLayout, shell.id).belowItem?.id).toBe(thinking.id);
expect(findLayoutItem(splitLayout, thinking.id).aboveItem?.id).toBe(shell.id);
expect(findLayoutItem(splitLayout, shell.id).toolSequence).toBe(
findLayoutItem(unsplitLayout, shell.id).toolSequence,
);
expect(findLayoutItem(splitLayout, thinking.id).toolSequence).toBe(
findLayoutItem(unsplitLayout, thinking.id).toolSequence,
);
expect(findLayoutItem(splitLayout, shell.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, shell.id).gapBelow,
);
expect(findLayoutItem(splitLayout, thinking.id).gapBelow).toBe(
findLayoutItem(unsplitLayout, thinking.id).gapBelow,
);
},
);
it("computes tool sequence position from strategy-aware neighbors", () => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);

View File

@@ -32,7 +32,6 @@ export interface StreamLayout {
history: StreamLayoutItem[];
liveHead: StreamLayoutItem[];
auxiliaryTurnFooter: TurnFooterHost | null;
historyToHeadGap: number;
}
export interface StreamLayoutInput {
@@ -243,6 +242,5 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history,
liveHead,
auxiliaryTurnFooter,
historyToHeadGap: getGapBetweenStreamItems(historyBoundaryItem, liveHeadBoundaryItem),
};
}

View File

@@ -19,7 +19,6 @@ export interface StreamHistoryBoundary {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
}
export interface StreamRenderAuxiliary {
@@ -205,7 +204,6 @@ export function buildAgentStreamRenderModel(
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
hasLiveHead: orderedHead.length > 0,
historyToHeadGap: 0,
},
auxiliary: EMPTY_AUXILIARY,
};

View File

@@ -125,7 +125,6 @@ describe("createWebStreamStrategy", () => {
hasVirtualizedHistory: true,
hasMountedHistory: false,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(rowRenderCount),
listEmptyComponent: null,
@@ -171,7 +170,6 @@ describe("createWebStreamStrategy", () => {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,

View File

@@ -584,9 +584,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
</div>
) : null}
{mountedHistoryRows}
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
<HistoryToHeadSpacer height={boundary.historyToHeadGap} />
) : null}
{liveHeadRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
@@ -634,12 +631,3 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
});
}
interface HistoryToHeadSpacerProps {
height: number;
}
function HistoryToHeadSpacer({ height }: HistoryToHeadSpacerProps) {
const spacerStyle = useMemo(() => ({ height, width: "100%" as const }), [height]);
return <div style={spacerStyle} />;
}

View File

@@ -43,13 +43,13 @@ import type { PendingPermission } from "@/types/shared";
import type {
AgentPermissionAction,
AgentPermissionResponse,
} from "@server/server/agent/agent-sdk-types";
} from "@getpaseo/protocol/agent-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
@@ -613,16 +613,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: streamLayout.historyToHeadGap,
},
boundary: baseRenderModel.boundary,
auxiliary: {
pendingPermissions: pendingPermissionsNode,
turnFooter: turnFooterNode,
},
};
}, [baseRenderModel, pendingPermissionsNode, streamLayout.historyToHeadGap, turnFooterNode]);
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(

View File

@@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
@@ -466,6 +467,7 @@ function AppContainer({
<WorktreeSetupCalloutSource />
<CommandCenter />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}

View File

@@ -1,149 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
const { redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
return {
redirectMock: vi.fn(),
state: hoistedState,
};
});
vi.mock("expo-router", () => ({
Redirect: ({ href }: { href: string }) => {
redirectMock(href);
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
},
usePathname: () => state.pathname,
}));
vi.mock("@/app/_layout", () => ({
useHostRuntimeBootstrapState: () => state.bootstrapState,
useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
shouldUseDesktopDaemon: () => false,
}));
vi.mock("@/screens/startup-splash-screen", () => ({
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
useLastWorkspaceSelection: () => state.workspaceSelection,
}));
describe("Index route startup navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.resetModules();
state.pathname = "/";
state.bootstrapState = {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
redirectMock.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
async function renderIndex() {
const { default: Index } = await import("./index");
await act(async () => {
root.render(<Index />);
});
}
it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
});
it("restores the persisted workspace even when the first online host is different", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("navigates to the host root when no persisted workspace exists", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = null;
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("falls back to welcome when the give-up timer fires with no host online", async () => {
state.bootstrapState = {
...state.bootstrapState,
hasGivenUpWaitingForHost: true,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/welcome");
});
});

View File

@@ -8,7 +8,7 @@ import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";

View File

@@ -0,0 +1,54 @@
import { File } from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
export type AttachmentFileInfo =
| { exists: true; isDirectory: boolean; size: number | null }
| { exists: false };
export interface AttachmentFileSystem {
readonly cacheDirectory: string | null;
getInfo(uri: string): Promise<AttachmentFileInfo>;
makeDirectory(uri: string, options: { intermediates: boolean }): Promise<void>;
writeBytes(uri: string, bytes: Uint8Array): Promise<void>;
copy(input: { from: string; to: string }): Promise<void>;
readAsBase64(uri: string): Promise<string>;
delete(uri: string, options: { idempotent: boolean }): Promise<void>;
listDirectory(uri: string): Promise<string[]>;
}
export function createExpoAttachmentFileSystem(): AttachmentFileSystem {
return {
cacheDirectory: FileSystem.cacheDirectory,
async getInfo(uri) {
const info = await FileSystem.getInfoAsync(uri);
if (!info.exists) {
return { exists: false };
}
const size =
typeof (info as { size?: number }).size === "number"
? (info as { size: number }).size
: null;
return { exists: true, isDirectory: info.isDirectory ?? false, size };
},
async makeDirectory(uri, options) {
await FileSystem.makeDirectoryAsync(uri, options);
},
async writeBytes(uri, bytes) {
new File(uri).write(bytes);
},
async copy(input) {
await FileSystem.copyAsync(input);
},
async readAsBase64(uri) {
return await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
},
async delete(uri, options) {
await FileSystem.deleteAsync(uri, options);
},
async listDirectory(uri) {
return await FileSystem.readDirectoryAsync(uri);
},
};
}

View File

@@ -1,64 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
const fileSystemMock = vi.hoisted(() => ({
getInfoAsync: vi.fn(async (uri: string) => {
if (uri.endsWith("/preview-assets/")) {
return { exists: true, isDirectory: true };
}
return { exists: true, isDirectory: false, size: 4 };
}),
makeDirectoryAsync: vi.fn(async () => {}),
writeAsStringAsync: vi.fn(async () => {}),
copyAsync: vi.fn(async () => {}),
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
}));
vi.mock("expo-file-system", () => ({
File: class {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
write(content: Uint8Array) {
fileSystemMock.fileWrite(this.uri, content);
}
},
}));
vi.mock("expo-file-system/legacy", () => ({
cacheDirectory: "file:///cache/",
EncodingType: { Base64: "base64" },
getInfoAsync: fileSystemMock.getInfoAsync,
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
copyAsync: fileSystemMock.copyAsync,
readAsStringAsync: fileSystemMock.readAsStringAsync,
deleteAsync: fileSystemMock.deleteAsync,
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
}));
import { createTestAttachmentFileSystem } from "./test-attachment-file-system";
describe("local file attachment store", () => {
beforeEach(() => {
fileSystemMock.getInfoAsync.mockClear();
fileSystemMock.makeDirectoryAsync.mockClear();
fileSystemMock.writeAsStringAsync.mockClear();
fileSystemMock.copyAsync.mockClear();
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
fileSystemMock.fileWrite.mockClear();
});
it("writes raw byte sources directly to the managed file path", async () => {
const fileSystem = createTestAttachmentFileSystem();
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
fileSystem,
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
@@ -69,11 +19,6 @@ describe("local file attachment store", () => {
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
});
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
new Uint8Array([0, 1, 2, 3]),
);
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
@@ -82,5 +27,9 @@ describe("local file attachment store", () => {
fileName: "result.png",
byteSize: 4,
});
expect(fileSystem.files.get("file:///cache/preview-assets/preview_8_test.png")).toEqual(
new Uint8Array([0, 1, 2, 3]),
);
expect(fileSystem.directories.has("file:///cache/preview-assets")).toBe(true);
});
});

View File

@@ -1,5 +1,4 @@
import { File } from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
import type { AttachmentFileSystem } from "@/attachments/attachment-file-system";
import {
type AttachmentStore,
type AttachmentStorageType,
@@ -37,12 +36,12 @@ function extensionForAttachment(params: { fileName?: string | null; mimeType: st
return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img";
}
async function ensureDirectory(uri: string): Promise<void> {
const info = await FileSystem.getInfoAsync(uri);
async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise<void> {
const info = await fileSystem.getInfo(uri);
if (info.exists && info.isDirectory) {
return;
}
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
await fileSystem.makeDirectory(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
@@ -55,6 +54,7 @@ async function blobToBytes(blob: Blob): Promise<Uint8Array> {
}
async function writeFromSource(input: {
fileSystem: AttachmentFileSystem;
source: SaveAttachmentInput["source"];
targetUri: string;
mimeType: string;
@@ -64,7 +64,7 @@ async function writeFromSource(input: {
if (from === input.targetUri) {
return;
}
await FileSystem.copyAsync({ from, to: input.targetUri });
await input.fileSystem.copy({ from, to: input.targetUri });
return;
}
@@ -77,7 +77,7 @@ async function writeFromSource(input: {
bytes = input.source.bytes;
}
new File(input.targetUri).write(bytes);
await input.fileSystem.writeBytes(input.targetUri, bytes);
}
function attachmentUri(metadata: AttachmentMetadata): string {
@@ -87,11 +87,13 @@ function attachmentUri(metadata: AttachmentMetadata): string {
export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
fileSystem: AttachmentFileSystem;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
}): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
const { fileSystem } = params;
const baseDirectory = fileSystem.cacheDirectory
? `${fileSystem.cacheDirectory}${params.baseDirectoryName}/`
: null;
async function resolveTarget(input: SaveAttachmentInput): Promise<{
@@ -103,10 +105,10 @@ export function createLocalFileAttachmentStore(params: {
storageKey: string;
}> {
if (!baseDirectory) {
throw new Error("expo-file-system cacheDirectory is unavailable.");
throw new Error("Attachment file-system cacheDirectory is unavailable.");
}
await ensureDirectory(baseDirectory);
await ensureDirectory(fileSystem, baseDirectory);
const id = input.id ?? generateAttachmentId();
let mimeTypeFromSource: string | undefined;
@@ -140,16 +142,14 @@ export function createLocalFileAttachmentStore(params: {
async save(input): Promise<AttachmentMetadata> {
const target = await resolveTarget(input);
await writeFromSource({
fileSystem,
source: input.source,
targetUri: target.targetUri,
mimeType: target.mimeType,
});
const info = await FileSystem.getInfoAsync(target.targetUri);
const byteSize =
info.exists && typeof (info as { size?: number }).size === "number"
? (info as { size: number }).size
: null;
const info = await fileSystem.getInfo(target.targetUri);
const byteSize = info.exists ? info.size : null;
return {
id: target.id,
mimeType: target.mimeType,
@@ -162,10 +162,7 @@ export function createLocalFileAttachmentStore(params: {
},
async encodeBase64({ attachment }): Promise<string> {
const uri = attachmentUri(attachment);
return await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
return await fileSystem.readAsBase64(attachmentUri(attachment));
},
async resolvePreviewUrl({ attachment }): Promise<string> {
@@ -184,22 +181,22 @@ export function createLocalFileAttachmentStore(params: {
: {}),
async delete({ attachment }): Promise<void> {
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
await fileSystem.delete(attachmentUri(attachment), { idempotent: true });
},
async garbageCollect({ referencedIds }): Promise<void> {
if (!baseDirectory) {
return;
}
await ensureDirectory(baseDirectory);
const entries = await FileSystem.readDirectoryAsync(baseDirectory);
await ensureDirectory(fileSystem, baseDirectory);
const entries = await fileSystem.listDirectory(baseDirectory);
await Promise.all(
entries.map(async (entryName) => {
const id = entryName.split(".", 1)[0] ?? "";
if (!id || referencedIds.has(id)) {
return;
}
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
await fileSystem.delete(`${baseDirectory}${entryName}`, {
idempotent: true,
});
}),

View File

@@ -1,3 +1,4 @@
import { createExpoAttachmentFileSystem } from "@/attachments/attachment-file-system";
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
@@ -5,6 +6,7 @@ export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "paseo-native-attachments",
fileSystem: createExpoAttachmentFileSystem(),
resolvePreviewUrl: async (attachment) => {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;

View File

@@ -9,7 +9,9 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
if (isElectronRuntime()) {
const { createDesktopAttachmentStore } =
await import("../desktop/attachments/desktop-attachment-store");
return createDesktopAttachmentStore();
const { createDesktopAttachmentBridge } =
await import("../desktop/attachments/desktop-attachment-bridge");
return createDesktopAttachmentStore(createDesktopAttachmentBridge());
}
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");

View File

@@ -0,0 +1,95 @@
import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system";
export interface TestAttachmentFileSystem extends AttachmentFileSystem {
readonly files: ReadonlyMap<string, Uint8Array>;
readonly directories: ReadonlySet<string>;
setFile(uri: string, bytes: Uint8Array): void;
setDirectory(uri: string): void;
}
export function createTestAttachmentFileSystem(options?: {
cacheDirectory?: string | null;
}): TestAttachmentFileSystem {
const files = new Map<string, Uint8Array>();
const directories = new Set<string>();
const cacheDirectory =
options && Object.hasOwn(options, "cacheDirectory")
? (options.cacheDirectory ?? null)
: "file:///cache/";
function describe(uri: string): AttachmentFileInfo {
if (directories.has(uri) || directories.has(stripTrailingSlash(uri))) {
return { exists: true, isDirectory: true, size: null };
}
const bytes = files.get(uri);
if (bytes) {
return { exists: true, isDirectory: false, size: bytes.byteLength };
}
return { exists: false };
}
return {
files,
directories,
cacheDirectory,
setFile(uri, bytes) {
files.set(uri, bytes);
},
setDirectory(uri) {
directories.add(stripTrailingSlash(uri));
},
async getInfo(uri) {
return describe(uri);
},
async makeDirectory(uri) {
directories.add(stripTrailingSlash(uri));
},
async writeBytes(uri, bytes) {
files.set(uri, bytes);
},
async copy({ from, to }) {
const bytes = files.get(from);
if (!bytes) {
throw new Error(`copy: source does not exist: ${from}`);
}
files.set(to, bytes);
},
async readAsBase64(uri) {
const bytes = files.get(uri);
if (!bytes) {
throw new Error(`readAsBase64: file does not exist: ${uri}`);
}
return toBase64(bytes);
},
async delete(uri, deleteOptions) {
if (!files.delete(uri) && !deleteOptions.idempotent) {
throw new Error(`delete: file does not exist: ${uri}`);
}
},
async listDirectory(uri) {
const prefix = uri.endsWith("/") ? uri : `${uri}/`;
const entries: string[] = [];
for (const path of files.keys()) {
if (path.startsWith(prefix)) {
entries.push(path.slice(prefix.length));
}
}
return entries;
},
};
}
function stripTrailingSlash(uri: string): string {
return uri.endsWith("/") ? uri.slice(0, -1) : uri;
}
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
if (typeof btoa === "function") {
return btoa(binary);
}
return Buffer.from(binary, "binary").toString("base64");
}

View File

@@ -1,4 +1,4 @@
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";

View File

@@ -3,7 +3,7 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { AgentAttachment } from "@server/shared/messages";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
export function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,

View File

@@ -28,9 +28,7 @@ import { isNative, isWeb } from "@/constants/platform";
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
export interface SheetHeaderSearch {
value: string;
onChange: (value: string) => void;
initialValue?: string;
resetKey?: string | number;
placeholder?: string;
autoFocus?: boolean;
@@ -167,6 +165,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
},
inlineTitle: {
flex: 1,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
@@ -202,6 +201,16 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
footer: {
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.surface2,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
},
@@ -352,9 +361,7 @@ export function SheetHeaderView({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
initialValue={search.initialValue}
resetKey={search.resetKey}
value={search.value}
onChangeText={handleSearchChange}
autoCapitalize="none"
autoCorrect={false}
@@ -371,7 +378,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading);
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
if (!hasInlineRow && !header.search) return null;
return (
<View>
@@ -398,6 +405,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
</View>
) : null}
{header.search ? (
@@ -407,9 +415,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
value={header.search.value}
onChangeText={header.search.onChange}
autoCapitalize="none"
autoCorrect={false}
@@ -427,6 +433,8 @@ export interface AdaptiveModalSheetProps {
visible: boolean;
onClose: () => void;
children: ReactNode;
/** Sticky footer rendered below the scrollable content. */
footer?: ReactNode;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
@@ -441,6 +449,7 @@ export function AdaptiveModalSheet({
visible,
onClose,
children,
footer,
snapPoints,
testID,
desktopMaxWidth,
@@ -506,6 +515,7 @@ export function AdaptiveModalSheet({
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -518,13 +528,13 @@ export function AdaptiveModalSheet({
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
</>
);

View File

@@ -4,7 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "@server/shared/agent-lifecycle";
} from "@getpaseo/protocol/agent-lifecycle";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";

View File

@@ -11,9 +11,19 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Search,
Settings,
Star,
} from "lucide-react-native";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -48,7 +58,6 @@ import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
getProviderDefaultLabel,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
@@ -79,6 +88,7 @@ interface CombinedModelSelectorProps {
onOpen?: () => void;
onClose?: () => void;
disabled?: boolean;
serverId?: string | null;
}
interface SelectorContentProps {
@@ -91,6 +101,7 @@ interface SelectorContentProps {
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
serverId: string | null;
}
function normalizeSearchQuery(value: string): string {
@@ -268,42 +279,60 @@ function FavoritesSection({
}
interface GroupProviderButtonProps {
providerId: string;
providerLabel: string;
rowCount: number;
defaultLabel: string | null;
provider: ProviderSelectorProvider;
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}
function GroupProviderButton({
providerId,
providerLabel,
rowCount,
defaultLabel,
onDrillDown,
onSelectDefault,
}: GroupProviderButtonProps) {
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.rowIconButton,
Boolean(hovered) && styles.rowIconButtonHovered,
pressed && styles.rowIconButtonPressed,
];
}
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(providerId);
const ProvIcon = getProviderIcon(provider.id);
const selection = provider.modelSelection;
const handlePress = useCallback(() => {
if (defaultLabel) {
onSelectDefault(providerId);
return;
}
onDrillDown(providerId, providerLabel);
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
onDrillDown(provider.id, provider.label);
}, [onDrillDown, provider.id, provider.label]);
let stateNode: React.ReactNode;
if (selection.kind === "models") {
const count = selection.rows.length;
stateNode = (
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
);
} else if (selection.kind === "loading") {
stateNode = (
<View style={styles.rowStateInline}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.drillDownCount}>Loading</Text>
</View>
);
} else {
stateNode = (
<View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>Error</Text>
</View>
);
}
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{providerLabel}</Text>
<Text style={styles.drillDownText}>{provider.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
</Text>
{defaultLabel ? null : (
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
{stateNode}
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
);
@@ -312,64 +341,22 @@ function GroupProviderButton({
function GroupedProviderRows({
providers,
onDrillDown,
onSelectDefault,
}: {
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}) {
return (
<View>
{providers.map((provider, index) => {
const rows = getProviderModelRows(provider);
const defaultLabel = getProviderDefaultLabel(provider);
return (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton
providerId={provider.id}
providerLabel={provider.label}
rowCount={rows.length}
defaultLabel={defaultLabel}
onDrillDown={onDrillDown}
onSelectDefault={onSelectDefault}
/>
</View>
);
})}
{providers.map((provider, index) => (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
</View>
))}
</View>
);
}
function DefaultProviderRow({
providerId,
isSelected,
onSelect,
}: {
providerId: string;
isSelected: boolean;
onSelect: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
onSelect(providerId, "");
}, [onSelect, providerId]);
const leadingSlot = useMemo(
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label="Default"
selected={isSelected}
onPress={handlePress}
leadingSlot={leadingSlot}
/>
);
}
function ProviderModelRows({
rows,
selectedProvider,
@@ -430,6 +417,31 @@ function ProviderModelRows({
);
}
function ProviderErrorEmptyState({
serverId,
providerId,
message,
}: {
serverId: string | null;
providerId: string;
message: string;
}) {
const { theme } = useUnistyles();
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
const handleRetry = useCallback(() => {
void refresh([providerId]);
}, [refresh, providerId]);
return (
<View style={styles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>{message}</Text>
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
{isRefreshing ? "Retrying…" : "Retry"}
</Button>
</View>
);
}
function SelectorContent({
view,
providers,
@@ -440,6 +452,7 @@ function SelectorContent({
onSelect,
onToggleFavorite,
onDrillDown,
serverId,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
@@ -461,12 +474,6 @@ function SelectorContent({
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, providers],
);
const handleSelectDefaultProvider = useCallback(
(providerId: string) => {
onSelect(providerId, "");
},
[onSelect],
);
const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
<View style={styles.emptyState}>
@@ -479,17 +486,28 @@ function SelectorContent({
if (!selectedViewProvider) {
return emptyState;
}
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
const drillSelection = selectedViewProvider.modelSelection;
if (drillSelection.kind === "loading") {
return (
<DefaultProviderRow
<View style={styles.emptyState}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.emptyStateText}>Loading</Text>
</View>
);
}
if (drillSelection.kind === "error") {
return (
<ProviderErrorEmptyState
serverId={serverId}
providerId={view.providerId}
isSelected={view.providerId === selectedProvider && !selectedModel}
onSelect={onSelect}
message={drillSelection.message}
/>
);
}
if (visibleRows.length === 0) {
return emptyState;
}
@@ -519,11 +537,7 @@ function SelectorContent({
/>
{providers.length > 0 ? (
<GroupedProviderRows
providers={providers}
onDrillDown={onDrillDown}
onSelectDefault={handleSelectDefaultProvider}
/>
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
) : null}
{!hasResults ? emptyState : null}
@@ -543,6 +557,7 @@ export function CombinedModelSelector({
onOpen,
onClose,
disabled = false,
serverId = null,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
@@ -648,6 +663,10 @@ export function CombinedModelSelector({
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
const handleClose = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
@@ -673,21 +692,42 @@ export function CombinedModelSelector({
setSearchQuery(value);
}, []);
const openHeaderProviderSettings = useCallback(() => {
if (!serverId || view.kind !== "provider") return;
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
handleClose();
}, [serverId, view, handleClose]);
const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
return { title: "Select provider" };
}
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
<Pressable
onPress={openHeaderProviderSettings}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Open ${view.providerLabel} settings`}
testID={`selector-header-settings-${view.providerId}`}
>
<Settings
size={theme.iconSize.sm}
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
/>
</Pressable>
);
return {
title: view.providerLabel,
leading: ProviderIconForView ? (
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
) : undefined,
back: singleProviderView ? undefined : { onPress: handleBackToAll },
actions: headerActions,
search: {
value: searchQuery,
onChange: handleSearchQueryChange,
initialValue: searchQuery,
resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: "Search models...",
autoFocus: platformIsWeb,
@@ -697,11 +737,15 @@ export function CombinedModelSelector({
}, [
view,
singleProviderView,
serverId,
openHeaderProviderSettings,
theme.colors.border,
theme.colors.foregroundMuted,
handleBackToAll,
handleSearchQueryChange,
searchQuery,
searchResetKey,
theme.iconSize.md,
theme.iconSize.sm,
theme.colors.foreground,
]);
@@ -760,6 +804,7 @@ export function CombinedModelSelector({
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onDrillDown={handleDrillDown}
serverId={serverId}
/>
) : (
<View style={styles.sheetLoadingState}>
@@ -857,6 +902,34 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
rowStateInline: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 1,
minWidth: 0,
},
rowErrorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
maxWidth: 140,
},
rowIconButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
rowSpinner: {
transform: [{ scale: 0.7 }],
},
rowIconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
rowIconButtonPressed: {
backgroundColor: theme.colors.surface1,
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: "center",

View File

@@ -189,7 +189,7 @@ const styles = StyleSheet.create((theme) => {
},
lineText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
...(isWeb
? {

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
describe("dragStateReducer", () => {
it("starts tracking the active item with a snapshot of the data", () => {
const next = dragStateReducer(dragStateInitial<string>(), {
type: "start",
id: "alpha",
data: ["alpha", "beta"],
});
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] });
});
it("clears the active item and the snapshot", () => {
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
{ type: "clear" },
);
expect(next).toEqual({ activeId: null, dragItems: null });
});
it("replaces an in-flight drag when a new one starts", () => {
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
{ type: "start", id: "beta", data: ["beta", "gamma"] },
);
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] });
});
});

View File

@@ -0,0 +1,19 @@
export interface DragState<T> {
activeId: string | null;
dragItems: T[] | null;
}
export type DragAction<T> = { type: "start"; id: string; data: T[] } | { type: "clear" };
export function dragStateInitial<T>(): DragState<T> {
return { activeId: null, dragItems: null };
}
export function dragStateReducer<T>(state: DragState<T>, action: DragAction<T>): DragState<T> {
switch (action.type) {
case "start":
return { activeId: action.id, dragItems: action.data };
case "clear":
return { activeId: null, dragItems: null };
}
}

View File

@@ -0,0 +1,12 @@
export { reorderItemsOnDragEnd } from "./reorder-items";
export type { DragEndInput } from "./reorder-items";
export {
getPointerActivationConstraint,
type PointerActivationConfig,
type PointerActivationConstraint,
} from "./pointer-activation";
export {
useDragReorderState,
type DragReorderHandlers,
type DragReorderState,
} from "./use-drag-reorder-state";

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getPointerActivationConstraint } from "./pointer-activation";
const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 };
describe("getPointerActivationConstraint", () => {
it("uses distance activation for default draggable rows", () => {
expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 });
});
it("requires a held pointer before activating handle-based drags", () => {
expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 });
});
});

View File

@@ -0,0 +1,19 @@
export type PointerActivationConstraint =
| { distance: number }
| { delay: number; tolerance: number };
export interface PointerActivationConfig {
defaultDistance: number;
holdDelayMs: number;
holdTolerance: number;
}
export function getPointerActivationConstraint(
useDragHandle: boolean,
config: PointerActivationConfig,
): PointerActivationConstraint {
if (useDragHandle) {
return { delay: config.holdDelayMs, tolerance: config.holdTolerance };
}
return { distance: config.defaultDistance };
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { reorderItemsOnDragEnd } from "./reorder-items";
const items = ["alpha", "beta", "gamma"];
const byValue = (item: string): string => item;
describe("reorderItemsOnDragEnd", () => {
it("moves the active item to the over position", () => {
expect(
reorderItemsOnDragEnd({
items,
activeId: "alpha",
overId: "gamma",
keyExtractor: byValue,
}),
).toEqual(["beta", "gamma", "alpha"]);
});
it("is a no-op when the drop target is missing", () => {
expect(
reorderItemsOnDragEnd({
items,
activeId: "alpha",
overId: null,
keyExtractor: byValue,
}),
).toBeNull();
});
it("is a no-op when the active and over items are the same", () => {
expect(
reorderItemsOnDragEnd({
items,
activeId: "beta",
overId: "beta",
keyExtractor: byValue,
}),
).toBeNull();
});
it("is a no-op when the active id is not in the list", () => {
expect(
reorderItemsOnDragEnd({
items,
activeId: "delta",
overId: "beta",
keyExtractor: byValue,
}),
).toBeNull();
});
it("is a no-op when the over id is not in the list", () => {
expect(
reorderItemsOnDragEnd({
items,
activeId: "alpha",
overId: "delta",
keyExtractor: byValue,
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,24 @@
import { arrayMove } from "@dnd-kit/sortable";
export interface DragEndInput<T> {
items: T[];
activeId: string;
overId: string | null | undefined;
keyExtractor: (item: T, index: number) => string;
}
export function reorderItemsOnDragEnd<T>({
items,
activeId,
overId,
keyExtractor,
}: DragEndInput<T>): T[] | null {
if (!overId || activeId === overId) return null;
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === activeId);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === overId);
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null;
return arrayMove(items, oldIndex, newIndex);
}

View File

@@ -0,0 +1,73 @@
import { useCallback, useReducer } from "react";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
import { reorderItemsOnDragEnd } from "./reorder-items";
export interface DragReorderHandlers {
onDragStart: (event: DragStartEvent) => void;
onDragCancel: () => void;
onDragEnd: (event: DragEndEvent) => void;
}
export interface DragReorderState<T> {
activeId: string | null;
items: T[];
handlers: DragReorderHandlers;
}
export function useDragReorderState<T>({
data,
keyExtractor,
onDragEnd,
onDragBegin,
disabled = false,
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
onDragEnd?: (items: T[]) => void;
onDragBegin?: () => void;
disabled?: boolean;
}): DragReorderState<T> {
const [state, dispatch] = useReducer(dragStateReducer<T>, undefined, dragStateInitial<T>);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
if (disabled) return;
dispatch({ type: "start", id: String(event.active.id), data });
onDragBegin?.();
},
[data, disabled, onDragBegin],
);
const clearDragState = useCallback(() => {
dispatch({ type: "clear" });
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const items = state.dragItems ?? data;
dispatch({ type: "clear" });
if (disabled) return;
const reordered = reorderItemsOnDragEnd({
items,
activeId: String(active.id),
overId: over ? String(over.id) : null,
keyExtractor,
});
if (reordered) onDragEnd?.(reordered);
},
[data, disabled, keyExtractor, onDragEnd, state.dragItems],
);
return {
activeId: state.activeId,
items: state.dragItems ?? data,
handlers: {
onDragStart: handleDragStart,
onDragCancel: clearDragState,
onDragEnd: handleDragEnd,
},
};
}

View File

@@ -1,167 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DraggableList } from "./draggable-list.web";
interface DndContextProps {
onDragStart?: (event: { active: { id: string } }) => void;
onDragCancel?: () => void;
}
let latestDndContextProps: DndContextProps | null = null;
const dndKitMocks = vi.hoisted(() => ({
useSensor: vi.fn(() => ({})),
}));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
closestCenter: vi.fn(),
KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(),
useSensor: dndKitMocks.useSensor,
useSensors: vi.fn(() => []),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: React.PropsWithChildren) => children,
arrayMove: <T,>(items: T[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
if (item !== undefined) {
next.splice(to, 0, item);
}
return next;
},
sortableKeyboardCoordinates: vi.fn(),
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
}),
verticalListSortingStrategy: {},
}));
vi.mock("./use-web-scrollbar", () => ({
useWebScrollViewScrollbar: () => ({
onLayout: vi.fn(),
onContentSizeChange: vi.fn(),
onScroll: vi.fn(),
overlay: null,
}),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
latestDndContextProps = null;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
const DATA: string[] = ["alpha", "beta"];
function keyExtractor(item: string): string {
return item;
}
function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
return (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
);
}
function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void {
act(() => {
root?.render(
<DraggableList
data={DATA}
keyExtractor={keyExtractor}
onDragEnd={vi.fn()}
renderItem={renderItem}
scrollEnabled={false}
useDragHandle={useDragHandle}
/>,
);
});
}
function getItemActiveState(item: string): string | null {
return (
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
);
}
describe("DraggableList web", () => {
it("uses distance activation for default draggable rows", () => {
renderList();
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
activationConstraint: { distance: 6 },
}),
);
});
it("requires a held pointer before activating handle-based drags", () => {
renderList({ useDragHandle: true });
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
activationConstraint: { delay: 250, tolerance: 8 },
}),
);
});
it("clears active drag state when a drag is cancelled", () => {
renderList();
act(() => {
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
});
expect(getItemActiveState("alpha")).toBe("true");
act(() => {
latestDndContextProps?.onDragCancel?.();
});
expect(getItemActiveState("alpha")).toBe("false");
});
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { useCallback, useMemo, useRef, type ReactElement } from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -8,19 +8,17 @@ import {
type Modifier,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -30,8 +28,11 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
});
const DND_MODIFIERS = [restrictToVerticalAxis];
const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 };
const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 };
const POINTER_ACTIVATION_CONFIG = {
defaultDistance: 6,
holdDelayMs: 250,
holdTolerance: 8,
};
interface SortableItemProps<T> {
id: string;
@@ -137,17 +138,21 @@ export function DraggableList<T>({
onDragBegin,
nestable: _nestable = false,
}: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const { activeId, items, handlers } = useDragReorderState({
data,
keyExtractor,
onDragEnd,
onDragBegin,
});
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(null);
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
const pointerActivationConstraint = useDragHandle
? HANDLE_POINTER_ACTIVATION_CONSTRAINT
: DEFAULT_POINTER_ACTIVATION_CONSTRAINT;
const pointerActivationConstraint = getPointerActivationConstraint(
useDragHandle,
POINTER_ACTIVATION_CONFIG,
);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -158,39 +163,6 @@ export function DraggableList<T>({
}),
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setDragItems(data);
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, onDragBegin],
);
const clearDragState = useCallback(() => {
setActiveId(null);
setDragItems(null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
clearDragState();
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
onDragEnd(newItems);
}
}
},
[clearDragState, items, keyExtractor, onDragEnd],
);
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
@@ -224,9 +196,9 @@ export function DraggableList<T>({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
@@ -255,9 +227,9 @@ export function DraggableList<T>({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {

View File

@@ -1158,7 +1158,7 @@ const styles = StyleSheet.create((theme) => ({
codeText: {
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.code,
flexShrink: 0,
},
previewImageScrollContent: {

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@server/client/daemon-client";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import {
ActivityIndicator,
@@ -172,15 +172,15 @@ const codeLineStyles = StyleSheet.create((theme) => ({
gutterText: {
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
flex: 1,
},
}));
@@ -216,9 +216,9 @@ function FilePreviewBody({
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
}, [highlightedLines, theme.fontSize.sm]);
const lineHeight = theme.fontSize.sm * 1.45;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
return null;

View File

@@ -1,13 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { MarkdownTextSpan } from "@/components/markdown-text";
import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
@@ -46,6 +40,10 @@ function fenceLanguageToExtension(info: string | null | undefined): string | nul
return LANGUAGE_ALIASES[normalized] ?? normalized;
}
function stripTerminalFenceNewline(code: string): string {
return code.endsWith("\n") ? code.slice(0, -1) : code;
}
// Cross-instance cache for tokenized code blocks. Tokenization is
// theme-independent (colors are applied at render time), so the key is just
// (language, code). Bounded by entry count — 200 is generous for a chat
@@ -87,23 +85,24 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
() => splitFenceStyle(inheritedStyles, textStyle),
[inheritedStyles, textStyle],
);
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
const keyedLines = useMemo<KeyedLine[] | null>(() => {
const ext = fenceLanguageToExtension(language);
if (!ext) return null;
const cacheKey = `${ext}:${code}`;
const cacheKey = `${ext}:${renderedCode}`;
const cached = tokenizationCache.get(cacheKey);
if (cached) return cached;
let tokenizedLines: HighlightToken[][];
try {
tokenizedLines = highlightCode(code, `x.${ext}`);
tokenizedLines = highlightCode(renderedCode, `x.${ext}`);
} catch {
return null;
}
const result = tokenizedLines.map(toKeyedLine);
tokenizationCache.set(cacheKey, result);
return result;
}, [code, language]);
}, [renderedCode, language]);
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
@@ -119,18 +118,9 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
onPointerLeave={handlePointerLeave}
>
{keyedLines ? (
<Text style={innerTextStyle}>
{keyedLines.map((line, lineIndex) => (
<React.Fragment key={line.key}>
{lineIndex > 0 ? "\n" : null}
{line.tokens.map(({ key, token }) => (
<TokenSpan key={key} token={token} />
))}
</React.Fragment>
))}
</Text>
<MarkdownTextSpan style={innerTextStyle}>{renderCodeSegments(keyedLines)}</MarkdownTextSpan>
) : (
<Text style={innerTextStyle}>{code}</Text>
<MarkdownTextSpan style={innerTextStyle}>{renderedCode}</MarkdownTextSpan>
)}
<CopyButton getCode={getCode} visible={controlsVisible} />
</View>
@@ -157,13 +147,38 @@ function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
};
}
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
const segments: React.ReactNode[] = [];
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
const line = keyedLines[lineIndex];
if (lineIndex > 0) {
segments.push(<CodeTextSpan key={`${line.key}-newline`} text={"\n"} />);
}
for (const { key, token } of line.tokens) {
segments.push(<TokenSpan key={`${line.key}-${key}`} token={token} />);
}
}
return segments;
}
interface TokenSpanProps {
token: HighlightToken;
}
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
if (!token.style) return token.text;
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
return (
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</MarkdownTextSpan>
);
});
interface CodeTextSpanProps {
text: string;
}
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
});
interface SplitStyles {
@@ -179,6 +194,7 @@ function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): Spli
const textOnly: TextStyle = { ...WEB_SELECTABLE };
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
if (fontSize !== undefined) textOnly.fontSize = fontSize;
if (fontSize !== undefined) textOnly.lineHeight = Math.round(fontSize * 1.45);
if (color !== undefined) textOnly.color = color;
return {
containerStyle: [box as ViewStyle, CONTAINER_BASE],

View File

@@ -5,7 +5,7 @@ import {
isKnownEditorTargetId,
type EditorTargetId,
type KnownEditorTargetId,
} from "@server/shared/messages";
} from "@getpaseo/protocol/messages";
interface EditorAppIconProps {
editorId: EditorTargetId;

View File

@@ -0,0 +1,273 @@
import { describe, expect, it } from "vitest";
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import {
aggregateSessionEntries,
ALL_FILTER_VALUE,
buildProviderLabelMap,
collectErroredProviderLabels,
computeEmptyState,
getPromptPreview,
getSessionTitle,
resolveProvidersToFetch,
type SessionsQueryResult,
sumFilteredAlreadyImportedCount,
} from "@/components/import-session-sheet-view-model";
function entry(
overrides: Partial<FetchRecentProviderSessionEntry> = {},
): FetchRecentProviderSessionEntry {
return {
providerId: "claude",
providerLabel: "Claude Code",
providerHandleId: "thread-1",
cwd: "/repo/paseo",
title: null,
firstPromptPreview: null,
lastPromptPreview: null,
lastActivityAt: "2026-04-30T10:00:00.000Z",
...overrides,
};
}
function settled(
data: SessionsQueryResult["data"],
flags?: Partial<Omit<SessionsQueryResult, "data">>,
): SessionsQueryResult {
return {
data,
isError: false,
isLoading: false,
isPending: false,
...flags,
};
}
describe("resolveProvidersToFetch", () => {
it("returns null when the daemon does not support provider snapshots", () => {
expect(resolveProvidersToFetch(false, [{ provider: "claude" }])).toBeNull();
});
it("returns null while snapshot entries have not loaded yet", () => {
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
});
it("returns only enabled importable providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude" },
{ provider: "codex" },
{ provider: "opencode", enabled: false },
{ provider: "z-ai" },
]);
expect(providers).toEqual(["claude", "codex"]);
});
it("returns an empty array when snapshot has no enabled importable providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude", enabled: false },
{ provider: "z-ai" },
]);
expect(providers).toEqual([]);
});
});
describe("buildProviderLabelMap", () => {
it("returns an empty map when snapshot entries are missing", () => {
expect(buildProviderLabelMap(undefined).size).toBe(0);
});
it("indexes labels by provider id, skipping entries without a label", () => {
const labels = buildProviderLabelMap([
{ provider: "claude", label: "Claude Code" },
{ provider: "codex" },
{ provider: "z-ai", label: "Z.AI" },
]);
expect(labels.get("claude")).toBe("Claude Code");
expect(labels.get("codex")).toBeUndefined();
expect(labels.get("z-ai")).toBe("Z.AI");
});
});
describe("aggregateSessionEntries", () => {
it("returns an empty array when no queries have data", () => {
expect(aggregateSessionEntries([settled(undefined)])).toEqual([]);
});
it("dedupes by providerId+providerHandleId across query results", () => {
const result = aggregateSessionEntries([
settled({
entries: [
entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
],
}),
settled({
entries: [
entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T11:00:00.000Z" }),
entry({ providerHandleId: "thread-2", lastActivityAt: "2026-04-30T09:00:00.000Z" }),
],
}),
]);
expect(result.map((e) => e.providerHandleId)).toEqual(["thread-1", "thread-2"]);
});
it("sorts collected entries by lastActivityAt descending", () => {
const result = aggregateSessionEntries([
settled({
entries: [
entry({ providerHandleId: "old", lastActivityAt: "2026-04-29T10:00:00.000Z" }),
entry({ providerHandleId: "new", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
],
}),
]);
expect(result.map((e) => e.providerHandleId)).toEqual(["new", "old"]);
});
});
describe("sumFilteredAlreadyImportedCount", () => {
it("returns 0 when no queries report a filtered count", () => {
expect(sumFilteredAlreadyImportedCount([settled({ entries: [] })])).toBe(0);
});
it("sums the filtered already-imported counts across queries", () => {
const total = sumFilteredAlreadyImportedCount([
settled({ entries: [], filteredAlreadyImportedCount: 2 }),
settled({ entries: [], filteredAlreadyImportedCount: 3 }),
settled(undefined),
]);
expect(total).toBe(5);
});
});
describe("collectErroredProviderLabels", () => {
it("returns no labels when no providers are being fetched", () => {
expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]);
});
it("returns labels for each errored provider, falling back to provider id", () => {
const labels = collectErroredProviderLabels(
["claude", "codex"],
[settled(undefined, { isError: true }), settled({ entries: [] })],
new Map([["claude", "Claude Code"]]),
);
expect(labels).toEqual(["Claude Code"]);
});
it("uses provider id when the label map has no entry", () => {
const labels = collectErroredProviderLabels(
["codex"],
[settled(undefined, { isError: true })],
new Map(),
);
expect(labels).toEqual(["codex"]);
});
});
describe("getSessionTitle", () => {
it("prefers the trimmed title", () => {
expect(getSessionTitle(entry({ title: " Importable " }))).toBe("Importable");
});
it("falls back to the trimmed first prompt preview when title is empty", () => {
expect(getSessionTitle(entry({ title: " ", firstPromptPreview: " Hello " }))).toBe("Hello");
});
it("falls back to Untitled session when both title and first prompt are blank", () => {
expect(getSessionTitle(entry({ title: null, firstPromptPreview: " " }))).toBe(
"Untitled session",
);
});
});
describe("getPromptPreview", () => {
it("prefers the trimmed last prompt preview", () => {
expect(
getPromptPreview(
entry({ lastPromptPreview: " later ", firstPromptPreview: " earlier " }),
),
).toBe("later");
});
it("falls back to the first prompt preview when last is blank", () => {
expect(
getPromptPreview(entry({ lastPromptPreview: " ", firstPromptPreview: " earlier " })),
).toBe("earlier");
});
it("falls back to a placeholder when both prompts are blank", () => {
expect(getPromptPreview(entry({ lastPromptPreview: null, firstPromptPreview: null }))).toBe(
"No prompt preview",
);
});
});
describe("computeEmptyState", () => {
const baseInputs = {
isLoadingSessions: false,
allQueriesErrored: false,
isQueryingProviders: true,
allQueriesSettled: true,
selectedProvider: ALL_FILTER_VALUE,
aggregatedCount: 0,
visibleCount: 0,
totalAlreadyImportedCount: 0,
providerLabelById: new Map<string, string>(),
};
it("hides the empty state while sessions are still loading", () => {
const result = computeEmptyState({ ...baseInputs, isLoadingSessions: true });
expect(result.showEmptyState).toBe(false);
});
it("hides the empty state when every query errored", () => {
const result = computeEmptyState({ ...baseInputs, allQueriesErrored: true });
expect(result.showEmptyState).toBe(false);
});
it("hides the empty state until every provider query has settled", () => {
const result = computeEmptyState({ ...baseInputs, allQueriesSettled: false });
expect(result.showEmptyState).toBe(false);
});
it("hides the empty state when there are visible entries", () => {
const result = computeEmptyState({
...baseInputs,
aggregatedCount: 2,
visibleCount: 2,
});
expect(result.showEmptyState).toBe(false);
});
it("shows the default no-sessions message when nothing is loaded and nothing is filtered", () => {
const result = computeEmptyState(baseInputs);
expect(result).toEqual({
showEmptyState: true,
emptyStateTitle: "No recent sessions to import.",
});
});
it("shows the already-imported message when imported entries were filtered out", () => {
const result = computeEmptyState({
...baseInputs,
totalAlreadyImportedCount: 4,
});
expect(result.emptyStateTitle).toBe("All recent sessions are already imported.");
});
it("shows a provider-scoped message when a filter hides aggregated entries", () => {
const result = computeEmptyState({
...baseInputs,
selectedProvider: "claude",
aggregatedCount: 3,
providerLabelById: new Map([["claude", "Claude Code"]]),
});
expect(result.emptyStateTitle).toBe("No Claude Code sessions found.");
});
it("falls back to the provider id when the filtered provider lacks a label", () => {
const result = computeEmptyState({
...baseInputs,
selectedProvider: "z-ai",
aggregatedCount: 1,
});
expect(result.emptyStateTitle).toBe("No z-ai sessions found.");
});
});

View File

@@ -0,0 +1,145 @@
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
export const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__";
export interface SessionsQueryResult {
data:
| {
entries: FetchRecentProviderSessionEntry[];
filteredAlreadyImportedCount?: number;
}
| undefined;
isError: boolean;
isLoading: boolean;
isPending: boolean;
}
export function resolveProvidersToFetch(
supportsSnapshot: boolean,
snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
): AgentProvider[] | null {
// COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
// providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
// we render an "update host" empty state instead of degrading. Drop this gate
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
if (!supportsSnapshot) return null;
if (!snapshotEntries) return null;
return snapshotEntries
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
.map((entry) => entry.provider);
}
export function buildProviderLabelMap(
snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
): Map<string, string> {
const map = new Map<string, string>();
if (!snapshotEntries) return map;
for (const entry of snapshotEntries) {
if (entry.label) {
map.set(entry.provider, entry.label);
}
}
return map;
}
export function aggregateSessionEntries(
queries: ReadonlyArray<SessionsQueryResult>,
): FetchRecentProviderSessionEntry[] {
const seen = new Set<string>();
const collected: FetchRecentProviderSessionEntry[] = [];
for (const query of queries) {
if (!query.data) continue;
for (const entry of query.data.entries) {
const key = `${entry.providerId}:${entry.providerHandleId}`;
if (seen.has(key)) continue;
seen.add(key);
collected.push(entry);
}
}
collected.sort(
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
);
return collected;
}
export function sumFilteredAlreadyImportedCount(
queries: ReadonlyArray<SessionsQueryResult>,
): number {
let total = 0;
for (const query of queries) {
total += query.data?.filteredAlreadyImportedCount ?? 0;
}
return total;
}
export function collectErroredProviderLabels(
providersToFetch: AgentProvider[] | null,
queries: ReadonlyArray<SessionsQueryResult>,
providerLabelById: ReadonlyMap<string, string>,
): string[] {
if (providersToFetch === null) return [];
const labels: string[] = [];
for (let index = 0; index < queries.length; index++) {
if (queries[index]?.isError) {
const provider = providersToFetch[index];
labels.push(providerLabelById.get(provider) ?? provider);
}
}
return labels;
}
export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
const title = entry.title?.trim();
if (title) {
return title;
}
const firstPromptPreview = entry.firstPromptPreview?.trim();
if (firstPromptPreview) {
return firstPromptPreview;
}
return "Untitled session";
}
export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
}
export interface EmptyStateInputs {
isLoadingSessions: boolean;
allQueriesErrored: boolean;
isQueryingProviders: boolean;
allQueriesSettled: boolean;
selectedProvider: string;
aggregatedCount: number;
visibleCount: number;
totalAlreadyImportedCount: number;
providerLabelById: ReadonlyMap<string, string>;
}
export function computeEmptyState(input: EmptyStateInputs): {
showEmptyState: boolean;
emptyStateTitle: string;
} {
const showEmptyState =
!input.isLoadingSessions &&
!input.allQueriesErrored &&
input.isQueryingProviders &&
input.allQueriesSettled &&
input.visibleCount === 0;
if (!showEmptyState) {
return { showEmptyState, emptyStateTitle: "" };
}
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
}
if (input.totalAlreadyImportedCount > 0) {
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
}
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
}

View File

@@ -4,8 +4,11 @@
import React, { type ReactNode } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type {
DaemonClient,
FetchRecentProviderSessionEntry,
} from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ImportSessionSheet } from "@/components/import-session-sheet";

View File

@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import type {
DaemonClient,
FetchRecentProviderSessionEntry,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { Inbox, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
@@ -12,12 +14,21 @@ import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/s
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import {
aggregateSessionEntries,
ALL_FILTER_VALUE,
buildProviderLabelMap,
collectErroredProviderLabels,
computeEmptyState,
getPromptPreview,
getSessionTitle,
PER_PROVIDER_LIMIT,
resolveProvidersToFetch,
sumFilteredAlreadyImportedCount,
} from "@/components/import-session-sheet-view-model";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
const ALL_FILTER_VALUE = "__all__";
type RecentProviderSessionsClient = Pick<
DaemonClient,
@@ -46,41 +57,6 @@ interface SessionsQueryConfig {
queryFn: () => Promise<RecentSessionsResponse>;
}
interface SessionsQueryResult {
data: RecentSessionsResponse | undefined;
isError: boolean;
isLoading: boolean;
isPending: boolean;
}
function resolveProvidersToFetch(
supportsSnapshot: boolean,
snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
): AgentProvider[] | null {
// COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
// providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
// we render an "update host" empty state instead of degrading. Drop this gate
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
if (!supportsSnapshot) return null;
if (!snapshotEntries) return null;
return snapshotEntries
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
.map((entry) => entry.provider);
}
function buildProviderLabelMap(
snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
): Map<string, string> {
const map = new Map<string, string>();
if (!snapshotEntries) return map;
for (const entry of snapshotEntries) {
if (entry.label) {
map.set(entry.provider, entry.label);
}
}
return map;
}
function buildSessionsQueriesConfig(args: {
providersToFetch: AgentProvider[] | null;
sessionsQueryRoot: ReadonlyArray<string | null>;
@@ -107,66 +83,6 @@ function buildSessionsQueriesConfig(args: {
}));
}
function aggregateSessionEntries(
queries: ReadonlyArray<SessionsQueryResult>,
): FetchRecentProviderSessionEntry[] {
const seen = new Set<string>();
const collected: FetchRecentProviderSessionEntry[] = [];
for (const query of queries) {
if (!query.data) continue;
for (const entry of query.data.entries) {
const key = `${entry.providerId}:${entry.providerHandleId}`;
if (seen.has(key)) continue;
seen.add(key);
collected.push(entry);
}
}
collected.sort(
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
);
return collected;
}
function sumFilteredAlreadyImportedCount(queries: ReadonlyArray<SessionsQueryResult>): number {
let total = 0;
for (const query of queries) {
total += query.data?.filteredAlreadyImportedCount ?? 0;
}
return total;
}
function collectErroredProviderLabels(
providersToFetch: AgentProvider[] | null,
queries: ReadonlyArray<SessionsQueryResult>,
providerLabelById: ReadonlyMap<string, string>,
): string[] {
if (providersToFetch === null) return [];
const labels: string[] = [];
for (let index = 0; index < queries.length; index++) {
if (queries[index]?.isError) {
const provider = providersToFetch[index];
labels.push(providerLabelById.get(provider) ?? provider);
}
}
return labels;
}
function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
const title = entry.title?.trim();
if (title) {
return title;
}
const firstPromptPreview = entry.firstPromptPreview?.trim();
if (firstPromptPreview) {
return firstPromptPreview;
}
return "Untitled session";
}
function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
}
interface SheetStatusMessagesProps {
isClientReady: boolean;
isSnapshotUnsupported: boolean;
@@ -219,42 +135,6 @@ function SheetStatusMessages({
);
}
interface EmptyStateInputs {
isLoadingSessions: boolean;
allQueriesErrored: boolean;
isQueryingProviders: boolean;
allQueriesSettled: boolean;
selectedProvider: string;
aggregatedCount: number;
visibleCount: number;
totalAlreadyImportedCount: number;
providerLabelById: ReadonlyMap<string, string>;
}
function computeEmptyState(input: EmptyStateInputs): {
showEmptyState: boolean;
emptyStateTitle: string;
} {
const showEmptyState =
!input.isLoadingSessions &&
!input.allQueriesErrored &&
input.isQueryingProviders &&
input.allQueriesSettled &&
input.visibleCount === 0;
if (!showEmptyState) {
return { showEmptyState, emptyStateTitle: "" };
}
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
}
if (input.totalAlreadyImportedCount > 0) {
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
}
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(

View File

@@ -0,0 +1,37 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
children: ReactNode;
}
// Android's <Text selectable> enables per-text-node selection natively. Each
// sibling Text is its own selection scope — drag can't span across siblings
// (that requires a single UITextView ancestor and is iOS-only).
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
return (
<Text selectable style={style}>
{children}
</Text>
);
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
// in ReactBaseTextShadowNode), so this isn't a crash-avoidance choice — but
// inline-placeholder spans collapse block-level children (e.g. paragraph
// images) into one-character placeholders, which destroys image row layout.
// <View> preserves the original block layout; the trade-off is no cross-span
// selection on Android (a UITextView-style trick has no Android equivalent).
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
return <View style={style}>{children}</View>;
}

View File

@@ -0,0 +1 @@
export * from "./markdown-text.ios";

View File

@@ -0,0 +1,45 @@
import { useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { UITextView } from "react-native-uitextview";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
children: ReactNode;
}
// Inline span backed by UITextView so iOS gets native word-selection handles.
// Used inside MarkdownParagraphView (which is also a UITextView on iOS); the
// library's TextAncestorContext hoists these into UITextViewChild nodes so
// selection drags can cross sibling spans (e.g. plain text → **bold** → code).
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
return (
<UITextView uiTextView selectable style={style}>
{children}
</UITextView>
);
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
// iOS-only: paragraph wraps in UITextView so the entire paragraph is one
// native text view. That's what unlocks cross-inline drag selection — handles
// can span every MarkdownTextSpan child inside this paragraph.
// ViewStyle is structurally compatible with the layout props paragraphs use
// (margin, padding, alignment); the cast lets the existing paragraphStyle
// flow through unchanged.
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(
() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET] as StyleProp<TextStyle>,
[paragraphStyle],
);
return (
<UITextView uiTextView selectable style={style}>
{children}
</UITextView>
);
}

View File

@@ -0,0 +1,31 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
children: ReactNode;
}
// react-native-web renders Text as <span>/<div> with `user-select: text`
// already applied via markdownStyleMapping. The web bundle must not import
// react-native-uitextview: its transitive import of codegenNativeComponent
// pulls in setUpReactDevTools, which doesn't resolve under Metro's web
// target in dev mode.
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
return <Text style={style}>{children}</Text>;
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
// Same shape as Android — paragraph is a View so block-level children (images)
// keep their natural layout. Web text selection already spans nested inline
// elements via CSS user-select, so no UITextView equivalent is needed.
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
return <View style={style}>{children}</View>;
}

View File

@@ -10,6 +10,7 @@ import {
ViewStyle,
type TextStyle,
} from "react-native";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import * as React from "react";
import {
useState,
@@ -49,7 +50,7 @@ import {
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { SPACING, type Theme } from "@/styles/theme";
import { type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
@@ -63,16 +64,17 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Fonts } from "@/constants/theme";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
@@ -100,9 +102,9 @@ import {
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
export type { InlinePathTarget } from "@/assistant-file-links";
@@ -410,13 +412,10 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
copyButton: {
alignSelf: "center",
width: 24,
height: 24,
padding: 0,
margin: 0,
padding: theme.spacing[1],
paddingTop: theme.spacing[1],
marginTop: 0,
marginRight: -theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
trailingRow: {
alignSelf: "flex-end",
@@ -1151,7 +1150,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return;
}
await writeMarkdownToRichClipboard(content);
await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment());
setCopied(true);
if (copyTimeoutRef.current) {
@@ -1527,7 +1526,7 @@ function MarkdownInheritedText({
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
return <Text style={style}>{children}</Text>;
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
}
interface MarkdownListItemContentProps {
@@ -1542,51 +1541,14 @@ function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemCon
return <View style={style}>{children}</View>;
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
return <View style={style}>{children}</View>;
}
// List spacing in markdown:
// - p -> list and list -> p use a slightly larger gap than p -> p, so lists
// read as their own section against surrounding prose.
// - list -> list keeps the normal p-to-p gap; back-to-back lists are
// continuous content, not section breaks.
//
// Paragraph's marginBottom is SPACING[3] = 12 (and marginTop is 0). To produce
// 16px gaps on p<->list transitions and 12px on list<->list, we add a constant
// marginTop on lists (4) and switch marginBottom by next-sibling type:
// p -> list = p.marginBottom(12) + list.marginTop(4) = 16
// list -> p = list.marginBottom(16) + p.marginTop(0) = 16
// list -> list = list.marginBottom(8) + list.marginTop(4) = 12
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1]; // 4
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4]; // 16
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2]; // 8
function getMarkdownListContextMarginBottom(node: ASTNode, parent: ASTNode[]): number {
const nextType = getMarkdownNextSiblingType(node, parent);
const nextIsList = nextType === "bullet_list" || nextType === "ordered_list";
return nextIsList ? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST : MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE;
}
interface MarkdownListViewProps {
baseStyle: ViewStyle;
marginBottom: number;
spacing: { marginTop: number; marginBottom: number };
children: ReactNode;
}
function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListViewProps) {
const style = useMemo(
() => [baseStyle, { marginTop: MARKDOWN_LIST_MARGIN_TOP, marginBottom }],
[baseStyle, marginBottom],
);
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
return <View style={style}>{children}</View>;
}
@@ -1748,7 +1710,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<MarkdownListView
key={node.key}
baseStyle={styles.bullet_list}
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
spacing={getMarkdownListSpacing(node, parent)}
>
{children}
</MarkdownListView>
@@ -1762,7 +1724,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<MarkdownListView
key={node.key}
baseStyle={styles.ordered_list}
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
spacing={getMarkdownListSpacing(node, parent)}
>
{children}
</MarkdownListView>
@@ -2013,7 +1975,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
},
metadataText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontSize: theme.fontSize.code,
fontFamily: Fonts.mono,
lineHeight: 16,
},

View File

@@ -7,7 +7,7 @@ import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolveProviderIconName } from "./provider-icon-name";
describe("resolveProviderIconName", () => {
it("returns the built-in identifier for known provider ids", () => {
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
});
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
});
it("falls back to the bot icon for unknown custom providers", () => {
expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" });
});
});

View File

@@ -0,0 +1,31 @@
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
export type ProviderIconName =
| { kind: "builtin"; id: BuiltinProviderIconName }
| { kind: "catalog"; id: string }
| { kind: "bot" };
const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
"claude",
"codex",
"copilot",
"kiro",
"opencode",
"pi",
]);
const CATALOG_ICON_PROVIDER_IDS: ReadonlySet<string> = new Set(
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])),
);
export function resolveProviderIconName(provider: string): ProviderIconName {
if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) {
return { kind: "builtin", id: provider as BuiltinProviderIconName };
}
if (CATALOG_ICON_PROVIDER_IDS.has(provider)) {
return { kind: "catalog", id: provider };
}
return { kind: "bot" };
}

View File

@@ -1,30 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("lucide-react-native", () => ({
Bot: function Bot() {
return null;
},
PackagePlus: function PackagePlus() {
return null;
},
}));
import { Bot, PackagePlus } from "lucide-react-native";
import { getProviderIcon } from "./provider-icons";
describe("getProviderIcon", () => {
it("keeps built-in provider icons", () => {
expect(getProviderIcon("kiro")).toBe(PackagePlus);
});
it("uses vendored ACP catalog icons for catalog provider ids", () => {
const icon = getProviderIcon("amp-acp");
expect(icon).not.toBe(Bot);
expect(getProviderIcon("amp-acp")).toBe(icon);
});
it("falls back to the robot icon for unknown custom providers", () => {
expect(getProviderIcon("custom-claude-profile")).toBe(Bot);
});
});

View File

@@ -7,6 +7,10 @@ import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import {
resolveProviderIconName,
type BuiltinProviderIconName,
} from "@/components/provider-icon-name";
export interface ProviderIconProps {
size: number;
@@ -15,7 +19,7 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
const PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconComponent> = {
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
@@ -42,22 +46,24 @@ function createCatalogIcon(provider: string, iconSvg: string): ProviderIconCompo
return CatalogProviderIcon;
}
function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined {
function getCatalogProviderIcon(provider: string): ProviderIconComponent {
const cached = catalogIconComponents.get(provider);
if (cached) {
return cached;
}
const iconSvg = CATALOG_ICON_SVGS.get(provider);
if (!iconSvg) {
return undefined;
}
const iconSvg = CATALOG_ICON_SVGS.get(provider) ?? "";
const icon = createCatalogIcon(provider, iconSvg);
catalogIconComponents.set(provider, icon);
return icon;
}
export function getProviderIcon(provider: string): ProviderIconComponent {
return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot;
const name = resolveProviderIconName(provider);
if (name.kind === "builtin") {
return BUILTIN_PROVIDER_ICONS[name.id];
}
if (name.kind === "catalog") {
return getCatalogProviderIcon(name.id);
}
return Bot;
}

View File

@@ -0,0 +1,26 @@
import { useCallback } from "react";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider);
const close = useProviderSettingsStore((state) => state.close);
const handleClose = useCallback(() => {
close();
}, [close]);
if (!serverId || !provider) {
return null;
}
return (
<ProviderDiagnosticSheet
provider={provider}
serverId={serverId}
visible
onClose={handleClose}
/>
);
}

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from "vitest";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
parseQuestionFormQuestions,
questionShowsTextInput,
resolveDismissLabel,
shouldSubmitEmptyOnDismiss,
} from "./question-form-card-core";
describe("question form card core", () => {
test("treats optional input prompts as skippable empty answers", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Optional comment?",
header: "Response",
options: [],
multiSelect: false,
placeholder: "Optional comment (press Enter to skip)...",
allowEmpty: true,
dismissLabel: "Skip",
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(areQuestionsAnswered(questions, {}, {})).toBe(true);
expect(buildQuestionFormAnswers(questions, {}, {})).toEqual({ Response: "" });
expect(shouldSubmitEmptyOnDismiss(questions)).toBe(true);
expect(resolveDismissLabel(questions)).toBe("Skip");
});
test("requires a selection for option-only questions", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Pick one",
header: "Response",
options: [{ label: "A" }, { label: "B" }],
multiSelect: false,
},
],
});
if (!questions) throw new Error("questions did not parse");
const [question] = questions;
if (!question) throw new Error("question missing");
expect(questionShowsTextInput(question)).toBe(false);
expect(areQuestionsAnswered(questions, {}, { 0: "freeform" })).toBe(false);
expect(areQuestionsAnswered(questions, { 0: new Set([1]) }, {})).toBe(true);
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
Response: "B",
});
});
test("shows text input for explicit other questions", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Pick or type",
header: "Response",
options: [{ label: "A" }],
isOther: true,
multiSelect: false,
},
],
});
if (!questions) throw new Error("questions did not parse");
const [question] = questions;
if (!question) throw new Error("question missing");
expect(questionShowsTextInput(question)).toBe(true);
expect(areQuestionsAnswered(questions, {}, { 0: "custom" })).toBe(true);
expect(buildQuestionFormAnswers(questions, {}, { 0: "custom" })).toEqual({
Response: "custom",
});
});
});

View File

@@ -0,0 +1,143 @@
export interface QuestionOption {
label: string;
description?: string;
}
export interface QuestionFormQuestion {
question: string;
header: string;
options: QuestionOption[];
multiSelect: boolean;
allowOther: boolean;
allowEmpty: boolean;
placeholder?: string;
dismissLabel?: string;
}
export type QuestionSelections = Record<number, ReadonlySet<number>>;
export type QuestionOtherTexts = Record<number, string>;
function readOptionalString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion[] | null {
if (
typeof input !== "object" ||
input === null ||
!("questions" in input) ||
!Array.isArray((input as Record<string, unknown>).questions)
) {
return null;
}
const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: QuestionFormQuestion[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return null;
const q = item as Record<string, unknown>;
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
if (!Array.isArray(q.options)) return null;
const options: QuestionOption[] = [];
for (const opt of q.options as unknown[]) {
if (typeof opt !== "object" || opt === null) return null;
const o = opt as Record<string, unknown>;
if (typeof o.label !== "string") return null;
options.push({
label: o.label,
description: typeof o.description === "string" ? o.description : undefined,
});
}
questions.push({
question: q.question,
header: q.header,
options,
multiSelect: q.multiSelect === true,
allowOther: q.allowOther === true || q.isOther === true,
allowEmpty: q.allowEmpty === true,
placeholder: readOptionalString(q, "placeholder"),
dismissLabel: readOptionalString(q, "dismissLabel"),
});
}
return questions.length > 0 ? questions : null;
}
export function questionShowsTextInput(question: QuestionFormQuestion): boolean {
return question.options.length === 0 || question.allowOther;
}
export function isQuestionAnswered(
question: QuestionFormQuestion,
qIndex: number,
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): boolean {
const selected = selections[qIndex];
if (selected && selected.size > 0) {
return true;
}
if (!questionShowsTextInput(question)) {
return false;
}
const otherText = otherTexts[qIndex]?.trim();
if (otherText && otherText.length > 0) {
return true;
}
return question.allowEmpty;
}
export function areQuestionsAnswered(
questions: QuestionFormQuestion[] | null,
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): boolean {
return (
questions?.every((question, qIndex) =>
isQuestionAnswered(question, qIndex, selections, otherTexts),
) ?? false
);
}
export function buildQuestionFormAnswers(
questions: QuestionFormQuestion[],
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): Record<string, string> {
const answers: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
answers[q.header] = otherText;
continue;
}
if (q.allowEmpty && q.options.length === 0) {
answers[q.header] = "";
continue;
}
}
if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
answers[q.header] = labels.join(", ");
}
}
return answers;
}
export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): boolean {
return (
questions.length > 0 &&
questions.every((question) => question.allowEmpty && question.options.length === 0)
);
}
export function resolveDismissLabel(questions: QuestionFormQuestion[]): string {
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss";
}

View File

@@ -11,56 +11,18 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
interface QuestionOption {
label: string;
description?: string;
}
interface Question {
question: string;
header: string;
options: QuestionOption[];
multiSelect: boolean;
}
function parseQuestions(input: unknown): Question[] | null {
if (
typeof input !== "object" ||
input === null ||
!("questions" in input) ||
!Array.isArray((input as Record<string, unknown>).questions)
) {
return null;
}
const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: Question[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return null;
const q = item as Record<string, unknown>;
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
if (!Array.isArray(q.options)) return null;
const options: QuestionOption[] = [];
for (const opt of q.options as unknown[]) {
if (typeof opt !== "object" || opt === null) return null;
const o = opt as Record<string, unknown>;
if (typeof o.label !== "string") return null;
options.push({
label: o.label,
description: typeof o.description === "string" ? o.description : undefined,
});
}
questions.push({
question: q.question,
header: q.header,
options,
multiSelect: q.multiSelect === true,
});
}
return questions.length > 0 ? questions : null;
}
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
parseQuestionFormQuestions,
questionShowsTextInput,
resolveDismissLabel,
shouldSubmitEmptyOnDismiss,
type QuestionFormQuestion,
type QuestionOption,
} from "./question-form-card-core";
interface QuestionFormCardProps {
permission: PendingPermission;
@@ -70,6 +32,12 @@ interface QuestionFormCardProps {
const IS_WEB = isWeb;
function getQuestionInputPlaceholder(question: QuestionFormQuestion): string {
return (
question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...")
);
}
interface QuestionOptionRowProps {
qIndex: number;
optIndex: number;
@@ -137,6 +105,7 @@ function QuestionOptionRow({
interface QuestionOtherInputProps {
qIndex: number;
value: string;
placeholder: string;
isResponding: boolean;
onChange: (qIndex: number, text: string) => void;
onSubmit: () => void;
@@ -145,6 +114,7 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
value,
placeholder,
isResponding,
onChange,
onSubmit,
@@ -179,7 +149,7 @@ function QuestionOtherInput({
<TextInput
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
placeholder="Other..."
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={handleChange}
@@ -193,7 +163,7 @@ function QuestionOtherInput({
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const questions = parseQuestions(permission.request.input);
const questions = parseQuestionFormQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
@@ -237,33 +207,17 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
}, []);
const allAnswered =
questions?.every((_, qIndex) => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
}) ?? false;
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return;
setRespondingAction("submit");
const answers: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
if (otherText && otherText.length > 0) {
answers[q.header] = otherText;
} else if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
answers[q.header] = labels.join(", ");
}
}
onRespond({
behavior: "allow",
updatedInput: { ...permission.request.input, answers },
updatedInput: {
...permission.request.input,
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
});
}, [
questions,
@@ -276,12 +230,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
]);
const handleDeny = useCallback(() => {
if (!questions) return;
setRespondingAction("dismiss");
if (shouldSubmitEmptyOnDismiss(questions)) {
onRespond({
behavior: "allow",
updatedInput: {
...permission.request.input,
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
});
return;
}
onRespond({
behavior: "deny",
message: "Dismissed by user",
});
}, [onRespond]);
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
const dismissButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -349,11 +314,14 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
return null;
}
const dismissLabel = resolveDismissLabel(questions);
return (
<View style={containerStyle}>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
const showTextInput = questionShowsTextInput(q);
return (
<View key={q.question} style={styles.questionBlock}>
@@ -361,27 +329,32 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
<Text style={questionTextStyle}>{q.question}</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
{q.options.length > 0 ? (
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
placeholder={getQuestionInputPlaceholder(q)}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
) : null}
</View>
);
})}
@@ -393,7 +366,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={dismissActionTextStyle}>Dismiss</Text>
<Text style={dismissActionTextStyle}>{dismissLabel}</Text>
</View>
)}
</Pressable>

View File

@@ -11,7 +11,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { type RewindMode, useRewindCapabilities } from "./use-rewind-capabilities";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
export type { RewindMode };
@@ -137,8 +137,8 @@ export const RewindMenu = memo(function RewindMenu({
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 24,
height: 24,
padding: theme.spacing[1],
paddingTop: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
@@ -147,7 +147,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: theme.opacity[50],
},
triggerSlot: {
alignSelf: "flex-start",
alignSelf: "center",
},
tooltipText: {
color: theme.colors.foreground,

View File

@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
export type RewindMode = "conversation" | "files" | "both";

View File

@@ -2,8 +2,8 @@
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceScriptPayload } from "@server/shared/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import React from "react";

View File

@@ -13,7 +13,7 @@ import {
} from "react-native";
import * as Haptics from "expo-haptics";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import {
@@ -27,7 +27,7 @@ import {
type Ref,
} from "react";
import { router, usePathname, type Href } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { type GestureType } from "react-native-gesture-handler";
@@ -99,7 +99,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/git/use-pr-status-query";
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -1346,14 +1346,7 @@ function WorkspaceRowInner({
const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspaceDirectory ?? "",
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
});
const prHint = workspace.prHint;
const interaction = useLongPressDragInteraction({
drag,
menuController,

View File

@@ -1,123 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SortableInlineList } from "./sortable-inline-list.web";
interface DndContextProps {
onDragStart?: (event: { active: { id: string } }) => void;
onDragCancel?: () => void;
}
let latestDndContextProps: DndContextProps | null = null;
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
closestCenter: vi.fn(),
KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: React.PropsWithChildren) => children,
arrayMove: <T,>(items: T[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
if (item !== undefined) {
next.splice(to, 0, item);
}
return next;
},
horizontalListSortingStrategy: {},
sortableKeyboardCoordinates: vi.fn(),
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
}),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
latestDndContextProps = null;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
const DATA: string[] = ["alpha", "beta"];
function keyExtractor(item: string): string {
return item;
}
function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
return (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
);
}
function renderList(): void {
act(() => {
root?.render(
<SortableInlineList
data={DATA}
keyExtractor={keyExtractor}
onDragEnd={vi.fn()}
renderItem={renderItem}
/>,
);
});
}
function getItemActiveState(item: string): string | null {
return (
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
);
}
describe("SortableInlineList web", () => {
it("clears active drag state when a drag is cancelled", () => {
renderList();
act(() => {
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
});
expect(getItemActiveState("alpha")).toBe("true");
act(() => {
latestDndContextProps?.onDragCancel?.();
});
expect(getItemActiveState("alpha")).toBe("false");
});
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useCallback, useMemo, type ReactElement } from "react";
import {
DndContext,
closestCenter,
@@ -7,18 +7,16 @@ import {
type Modifier,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableRenderItemInfo } from "./draggable-list.types";
import { useDragReorderState } from "./drag-reorder";
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
...transform,
@@ -138,9 +136,19 @@ export function SortableInlineList<T>({
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = externalDndContext ? data : (dragItems ?? data);
const {
activeId: internalActiveId,
items: managedItems,
handlers,
} = useDragReorderState({
data,
keyExtractor,
onDragEnd,
onDragBegin,
disabled,
});
const items = externalDndContext ? data : managedItems;
const activeId = externalDndContext ? externalActiveId : internalActiveId;
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -153,46 +161,6 @@ export function SortableInlineList<T>({
}),
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
if (disabled) {
return;
}
setDragItems(data);
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, disabled, onDragBegin],
);
const clearDragState = useCallback(() => {
setActiveId(null);
setDragItems(null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
clearDragState();
if (disabled) {
return;
}
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
onDragEnd?.(newItems);
}
}
},
[clearDragState, disabled, items, keyExtractor, onDragEnd],
);
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
@@ -209,7 +177,7 @@ export function SortableInlineList<T>({
item={item}
index={index}
renderItem={renderItem}
activeId={externalDndContext ? externalActiveId : activeId}
activeId={activeId}
useDragHandle={useDragHandle}
disabled={disabled}
itemData={getItemData?.(item, index)}
@@ -229,9 +197,9 @@ export function SortableInlineList<T>({
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
{renderedItems}
</DndContext>

View File

@@ -18,8 +18,8 @@ import {
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,

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