Compare commits

...

116 Commits

Author SHA1 Message Date
Mohamed Boudra
32d9147499 chore(release): cut 0.1.63-beta.1 2026-04-24 13:12:14 +07:00
Mohamed Boudra
ad0e754cc1 docs: add 0.1.63-beta.1 changelog entry 2026-04-24 13:11:20 +07:00
Mohamed Boudra
d152b4f055 Improve terminal renderer recovery 2026-04-24 13:05:58 +07:00
Mohamed Boudra
c92e4e46d2 Stabilize git diff list heights 2026-04-24 12:26:36 +07:00
Mohamed Boudra
691e2ee7e6 Preserve live stream head during timeline catch-up 2026-04-24 12:01:33 +07:00
Mohamed Boudra
6b84c8963d Scope pre-commit lint and format to staged files 2026-04-24 12:00:26 +07:00
Mohamed Boudra
0c3d15ffb9 Stabilize web timeline height estimates 2026-04-24 11:55:43 +07:00
Mohamed Boudra
480b18c500 Document targeted npm lint and format scripts 2026-04-24 11:52:42 +07:00
Mohamed Boudra
f9e18424e9 Require lint after changes 2026-04-24 11:49:16 +07:00
Mohamed Boudra
52eb307e8c Stop logging ACP non-JSON stdout previews 2026-04-24 11:42:59 +07:00
Mohamed Boudra
50a12e2140 Reduce assistant file link parser complexity 2026-04-24 11:34:37 +07:00
Mohamed Boudra
d118e2349e Fix assistant file links with line suffixes 2026-04-24 11:32:16 +07:00
Mohamed Boudra
446dd9cfe6 chore: drive linter to zero, promote warn -> error in CI (#545)
* chore(lint): use Set for LOG_FORMATS membership check

* chore(lint): convert type aliases to interfaces (autofix)

- Remove no-use-before-define rule (conflicts with unistyles ordering)
- Add typescript/consistent-type-definitions: interface
- Run oxlint --fix: 606 type->interface conversions across 268 files

Typecheck green. Warnings: 5432 -> 3787 (-1645).

* chore(lint): clean up relay package warnings

* chore(lint): clean up cli package warnings

* chore(lint): flatten nested ternaries in server (no-nested-ternary)

* chore(lint): escape entities and hoist inline objects in website

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* fix(types): restore Record assignability after type->interface autofix

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): clean up desktop and highlight packages

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): format keyboard-shortcuts-section

* chore(lint): rename shadowed bindings in server (no-shadow)

Rename inner bindings that shadow outer imports or function params:
- Promise executor `resolve` -> `resolvePromise` (shadowed path `resolve`)
- Method params `options` -> `input`/`target`/`update`/`runOptions`/`opts`/`killOptions`
- Misc loop/destructure renames for `workspaceId`, `scriptNames`, `path`, `query`, `taskNotificationItem`

Mechanical change only; no behavior change.

* fix(types): add index signatures for Record assignability

* chore(lint): extract nested callbacks in server (max-nested-callbacks)

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

Work in progress: 30 of 369 warnings fixed across 23 files.

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

* chore(lint): reduce cyclomatic complexity in server

* chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)

* chore(lint): parallelize safe await-in-loop in server

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

Finish eliminating jsx-no-new-function-as-prop warnings in
agent-status-bar.tsx and complete refactor of git-diff-pane.tsx
by extracting per-item components and using stable useCallback
handlers.

* chore(lint): parallelize more safe await-in-loop in server

* fix(server): restore microtask ordering in message dispatch and stream event handler

The complexity refactor added async dispatcher chains that inserted extra
microtasks before message handlers ran, and wrapped the stream event switch
in an await that fired between emitState and dispatchStream. Two tests
regressed on both counts. Route to the matching dispatcher synchronously
and skip the await when the stream handler has no async work.

* chore(lint): hoist/memoize inline arrays and objects in app JSX

* chore(lint): parallelize safe awaits in server (no-await-in-loop)

Converts three sequential `for ... await` loops to `Promise.all`:
- OpenCode: configure MCP servers in parallel
- Sherpa: download model files and ensure multiple models concurrently
- Speech runtime: check required model files across models in parallel

* chore(lint): clear website warnings to zero

Resolves all 61 oxlint warnings in packages/website/ by hoisting inline
callbacks/JSX, extracting sub-components to satisfy jsx-max-depth, using
stable data-derived keys, adding explicit button types, flattening nested
ternaries, and removing unused code.

* chore(lint): parallelize more safe awaits in server (no-await-in-loop)

- Workspace reconciliation: archive missing workspaces and orphaned
  projects concurrently instead of sequentially.
- Directory suggestions: resolve child directory candidates in parallel
  before filtering.

* chore(lint): allow css imports in import/no-unassigned-import, hoist host page styles

* chore(lint): hoist dictation-controls inline arrays

* fix(server): restore microtask ordering in session message dispatch

Converts per-group dispatchers to Promise<void> | undefined. The complexity
refactor broke two tests by inserting extra microtasks between each
dispatcher's await. The new pattern routes synchronously via a nullish-
coalescing chain and awaits only the matching dispatcher's promise.

* chore(lint): flatten nested ternaries in button/volume-meter/autocomplete

* chore(lint): narrow explicit any in misc server files

* chore(lint): flatten nested ternaries in host-runtime/host-page/providers

* chore(lint): clear desktop warnings to zero

Convert polling loops to recursive helpers to avoid no-await-in-loop,
and switch the ws import to the named export so the WebSocket type is
referenced directly.

* chore(lint): remove useless constructors in server tests

* chore(lint): hoist pair-scan inline arrays and objects

Extracts corner style arrays and barcode scanner settings to module
scope, memoizes insets-dependent body/helper text styles.

* chore(lint): hoist inline style arrays in question-form-card

* chore(lint): hoist/memoize inline styles in plan-card

Extracts markdown rule style arrays into dedicated MarkdownInlineText,
MarkdownListItemContent, and MarkdownParagraph subcomponents that
memoize their own style arrays. Memoizes PlanCard container/title/
description styles.

* chore(lint): narrow explicit any in server relay/loader/logger-likes

* chore(lint): hoist SheetBackground combined style

* chore(lint): hoist inline styles in screen-header

* chore(lint): hoist inline styles/objects in menu-header

* chore(lint): hoist top-level inline arrays in diff-viewer

* chore(lint): narrow explicit any in codex-app-server-agent thread items

* chore(lint): reduce max-depth in claude-sdk-behavior test

* chore(lint): memoize inline styles in command-center

* chore(lint): extract helper to reduce max-depth in agent-response-loop

* chore(lint): flatten nested conditions in pi-direct-agent history

* chore(lint): reduce max-depth in claude-agent query pump

* chore(lint): clear cli warnings to zero

Refactor polling loops into recursive helpers to satisfy
no-await-in-loop, fix WebSocket named import, and correct
an absolute-path import in tests/tmp.

* chore(lint): memoize inline styles in context-menu

* chore(lint): narrow explicit any in claude-agent/openai stt

* chore(lint): reduce max-depth in opencode-agent foreground loop

* chore(lint): memoize inline styles in explorer-sidebar

* chore(lint): memoize mobile sidebar styles in explorer-sidebar

* chore(lint): memoize inline styles in agent-list

* chore(lint): reduce relay test no-await-in-loop warnings

Refactor while-loops and for-retry loops in e2e.test.ts and
live-relay.e2e.test.ts into recursive poll/attempt helpers.
Remaining 8 warnings in encrypted-channel.ts concern the custom
Transport interface (on-handler slots, not DOM EventTarget) and a
sequential send loop; both reflect deliberate runtime contracts.

* chore(lint): fix typecheck errors from hoisting refactors

- pair-scan: type BARCODE_SCANNER_SETTINGS as BarcodeSettings
- explorer-sidebar: add missing desktopSidebarStyle useMemo
- sidebar-workspace-list: coerce null dotColor to transparent
- menu-header, e2e.test: formatting

* chore(lint): memoize inline styles in workspace-screen

* chore(lint): memoize inline styles in workspace-desktop-tabs-row

* chore(lint): hoist constant style arrays in desktop-updates-section

* chore(lint): memoize inline styles in combobox

* chore(lint): extract SplitGroupChild to memoize inline styles

* chore(lint): memoize inline styles in dropdown-menu

* chore(lint): memoize inline styles in workspace-hover-card

* chore(lint): memoize inline styles in autocomplete, message-input, composer

* chore(lint): remove explicit any in server (129 warnings)

- session.ts: catch(error: any) -> catch(error) with Error coercion at use
- daemon-client transport: any -> unknown in listener types
- sherpa/onnx loaders: introduce structural native types
- pocket-tts-onnx: typed ONNX session inputs/outputs/tensors
- tests: any -> unknown + named stub types

* chore(lint): hoist inline arrays and objects in app (145 warnings)

- Hoist static style arrays/objects to module-level consts
- Memoize dynamic ones with useMemo and correct deps
- jsx-no-new-array-as-prop: 145 -> 43
- jsx-no-new-object-as-prop: 64 -> 21

* chore(lint): memoize inline styles in _layout

Fixes 3 react-perf/jsx-no-new-object-as-prop warnings in root layout by
memoizing the stack screen options, the agent screen override, and the
GestureHandlerRootView root style.

* chore(lint): memoize inline styles and objects in stream/status panes

Clears 10 react-perf/jsx-no-new warnings across welcome-screen,
file-explorer-pane, terminal-pane, agent-stream-view, and
agent-status-bar by extracting per-item subcomponents, hoisting constant
style tuples, and memoizing derived arrays/objects.

* chore(lint): memoize inline style objects in list/pane components

Clears 6 react-perf/jsx-no-new-object-as-prop warnings across
synced-loader, stream-strategy-web, sortable-inline-list, file-pane, and
both draggable-list platform variants by memoizing or hoisting their
inline style objects.

* chore(lint): hoist test fixture arrays/objects

Clears 14 react-perf/jsx-no-new-* warnings across app test files by
hoisting constant fixtures to module scope or wrapping them in helper
factories so they no longer appear as inline JSX prop values.

* chore(lint): memoize inline styles in message components

Clears 5 react-perf/jsx-no-new-* warnings in message.tsx by memoizing
image source objects, extracting an AssistantMessageBlockContainer for
per-block spacing, and moving todo list item rendering into a
TodoListItemRow subcomponent so each row memoizes its own style arrays.
The remaining 8 warnings in react-native-markdown-display render rules
are inherent to that library's rule API.

* chore(lint): remove unused vitest imports in server tests

* chore(lint): remove unused helpers and vars in server tests

* chore(lint): add explicit returns in then() callbacks in server

* chore(lint): fix no-shadow and no-nested-ternary in server

* chore(lint): flatten nested blocks to satisfy max-depth in server

* chore(lint): extract helpers to satisfy max-depth in server

* chore(lint): parallelize Claude persisted agents lookup

Replace sequential parseClaudeSessionDescriptor loop with Promise.all
to fix no-await-in-loop warning.

* chore(lint): parallelize Linux watch directory traversal

Switch BFS to level-by-level Promise.all over readdir calls, replacing
the sequential pop/await loop that triggered no-await-in-loop.

* chore(lint): parallelize workspace registry bootstrap upserts

Collect per-workspace upsert inputs in a sync pass, then run registry
writes via Promise.all to eliminate no-await-in-loop warnings.

* chore(lint): parallelize test daemon cleanup rm calls

Run the paseoHomeRoot/staticDir removals concurrently in close() and
the startup catch block to fix no-await-in-loop.

* chore(lint): mechanical cleanup in app (unused, shadow, nested-ternary)

Recovers in-flight edits from the app mechanical agent that couldn't be
pushed due to concurrent tree contention. Removes unused helpers and
locals, flattens nested ternaries, narrows a few props where unused.

* chore(lint): parallelize pending permission approvals

approvePendingPermissions now filters and records handled IDs in a
sync pass, then awaits all allowPermission calls concurrently instead
of looping awaits.

* chore(lint): parallelize rebase head-name lookup

Try both rebase backends (rebase-merge, rebase-apply) concurrently
via Promise.all and return the first non-null result.

* chore(lint): no-unused-vars in app

* chore(lint): parallelize worktree terminal bootstrap

Start each bootstrap terminal concurrently via Promise.all. Since every
spec creates an independent terminal and returns a standalone result,
parallelization preserves order while removing two no-await-in-loop
warnings.

* chore(lint): parallelize worktree bootstrap test cleanup and script spawn

Run terminal cleanup across managers and spawnWorkspaceScript for api
and web concurrently to eliminate no-await-in-loop warnings while
preserving semantics.

* chore(lint): prefer-add-event-listener in app

* chore(lint): parallelize script-health-monitor afterEach and spawns

Close all stub TCP servers concurrently and spawn typecheck/api
scripts via Promise.all to drop two no-await-in-loop warnings.

* chore(lint): jsx-no-useless-fragment in app

* chore(lint): parallelize executable PATH probing

* chore(lint): parallelize client and relay transport test cleanup

* chore(lint): parallelize agent storage record file probing

* chore(lint): no-array-index-key in app

* chore(lint): parallelize bootstrap provider availability cleanup

* chore(lint): parallelize Codex rollout file search

* chore(lint): parallelize dictation wav fixture search

* chore(lint): parallelize Claude session id fixture test

* chore(lint): parallelize script health monitor server cleanup

* chore(lint): parallelize Codex skills directory scan

* chore(lint): promise/always-return in app

* chore(lint): no-shadow in use-dictation, hoist icons in splash screen

* chore(lint): no-shadow in composer callbacks

* chore(format): apply oxfmt to 3 server files

* chore(lint): no-explicit-any in stt-manager/chat-mentions tests

* chore(lint): no-shadow in small app files

Rename inner shadowing identifiers in:
- add-host-modal, agent-status-bar, sidebar-workspace-list, terminal-pane
- workspace-setup-dialog, session-context, use-is-local-daemon
- setup-panel, new-workspace-screen, workspace-desktop-tabs-row

* chore(lint): no-explicit-any in claude-agent.test

* chore(lint): no-shadow in small app test/e2e files

* chore(lint): no-explicit-any in workspace-git-service.test

* chore(lint): no-shadow in app test hoisted blocks

* chore(lint): no-explicit-any in websocket-server.runtime-metrics.test

* chore(lint): no-explicit-any in acp-agent.test

* chore(lint): no-shadow in app composer/message-input/e2e helpers

* chore(lint): no-explicit-any in session.workspace-git-watch.test

* chore(lint): no-explicit-any in websocket-server.notifications.test

* chore(lint): no-shadow in sidebar-workspace-list.test, use-pr-pane-data.test

* chore(lint): no-explicit-any in websocket-server.relay-reconnect.test

* chore(lint): no-explicit-any in codex-app-server-agent.test

* chore(lint): no-shadow in composer.test, host-runtime.test, new-workspace-screen.test

* chore(lint): no-explicit-any in codex-app-server-agent.features.test

* chore(lint): hoist jsx-as-prop in app components batch 1

Memoize inline JSX icons passed as props across add-host-modal,
branch-switcher, composer, pair-link-modal, pr-pane, and
sidebar-workspace-list to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): no-explicit-any in session.test

* chore(lint): hoist jsx-as-prop in app components batch 2

Memoize inline JSX passed as props across agent-list, agent-status-bar,
file-explorer-pane, git-actions-split-button, and message to satisfy
react-perf/jsx-no-jsx-as-prop.

* chore(lint): hoist jsx-as-prop in app components batch 3

Memoize inline JSX passed as props across combined-model-selector,
draggable-list.native, provider-diagnostic-sheet, and
workspace-setup-dialog to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): hoist jsx-as-prop in desktop components

Memoize inline JSX passed as props across desktop-permissions-section,
desktop-updates-section, integrations-section, and pair-device-section
to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): no-explicit-any in mcp-server.test

* chore(lint): hoist jsx-as-prop in workspace/new-workspace screens

* chore(lint): hoist jsx-as-prop in remaining screens

* chore(lint): no-explicit-any in session.workspaces.test

* chore(lint): no-explicit-any in snapshot-mutation-ownership.test

* chore(lint): no-explicit-any in dictation-stream-manager.test

* chore(lint): no-explicit-any in relay-transport.e2e.test

* chore(lint): no-explicit-any in worktree-session.test

* chore(lint): no-explicit-any in model-resolver.test

* chore(lint): no-explicit-any in sherpa-parakeet-stt.test

* chore(lint): no-explicit-any in speech-download.e2e.test

* chore(lint): no-explicit-any in script-health-monitor.test

* chore(lint): no-explicit-any in schedule/service.test

* chore(lint): no-explicit-any in persistence-hooks.test

* chore(lint): no-explicit-any in editor-targets.test

* chore(lint): no-explicit-any in send-while-running-stuck-test-utils.test

* chore(lint): no-explicit-any in agent-storage.test

* chore(lint): no-nested-ternary in app batch 1 (18 files)

* chore(lint): no-explicit-any in generate-sherpa-tts-matrix

* chore(lint): no-explicit-any in claude-agent

* chore(lint): no-explicit-any in websocket-server

* chore(lint): no-explicit-any in process-conversation

* chore(lint): no-explicit-any in daemon-client

* chore(lint): no-explicit-any in codex-app-server-agent

* chore(lint): no-nested-ternary in app batch 2 (26 files)

* chore(format): apply oxfmt to server test files

* chore(lint): parallelize agent archiving in test-mcp-inject

* chore(lint): no-explicit-any in small app files (batch 1)

* chore(lint): no-explicit-any in small app files (batch 2)

* chore(lint): no-explicit-any in app (batch 3)

Covers use-web-scrollbar, stream-strategy, dictation-stream-sender test,
terminal-perf helper, checkout-git-actions-store test, and
web-desktop-scrollbar.

* chore(lint): no-explicit-any in app (batch 4)

Covers composer, message-input, tooltip, workspace-desktop-tabs-row,
and the e2e helpers (app, node-ws-factory, terminal-probes).

* chore(lint): no-explicit-any in polyfills/crypto.ts

* chore(lint): no-explicit-any in components/sidebar-workspace-list.tsx

* chore(lint): prefer-array-find over filter().at/pop

Replace filter(pred).at(-1)/pop() patterns with findLast(pred) and
filter(pred)[0] with find(pred) across server and app.

* chore(lint): no-explicit-any in components/message.tsx

* chore(lint): no-explicit-any in components/plan-card.tsx

* chore(lint): no-map-spread replace with Object.assign

Replace { ...obj, override } inside map callbacks with Object.assign({}, obj, { override })
to satisfy oxc/no-map-spread. Preserves copy-on-write semantics.

* chore(lint): no-extraneous-class in test mocks

Add dispose() stubs to xterm addon mocks to satisfy typescript-eslint/no-extraneous-class, and convert static-only Notification mocks to plain objects.

* chore(lint): no-named-as-default use named imports for ws and openai

* chore(lint): jsx-no-new-array-as-prop hoist composed style in volume-display

Memoize the style array so it is not created inline on every render.

* chore(lint): prefer-add-event-listener use Object.assign for non-DOM handlers

These transports (Transport interface, StreamableHTTPServerTransport) use
plain on<event> properties rather than EventTarget, so addEventListener is
not an option. Using Object.assign to set the handlers avoids the lint
false-positive without changing behavior.

* chore(lint): no-multiple-resolved null pendingResolve after settling

Replace boolean-settled guards with a nullable captured resolve/reject.
Each promise callback captures the resolver, nulls it on first use, and
calls it. This preserves the existing single-resolution semantics while
making the no-multiple-resolved rule happy.

* chore(lint): reduce structural complexity in server

Extract nested-callback cleanup helpers in worktree-bootstrap.test. Extract Codex model definition builder to drop list-models complexity below 20.

* chore(lint): extract helpers in agent-activity and session-store-hooks.test

Split tool-call update handling in groupActivities into helpers to clear max-depth. Hoist useWorkspaceFields selector in the hooks test to drop callback nesting.

* chore(lint): rules-of-hooks and exhaustive-deps in agent-stream-view

Move useMemo calls for permission card styles above the early return
so hooks run unconditionally. Add missing dependencies to the inline
path press handler and the stream render callback.

* chore(lint): rules-of-hooks in diff-scroll via optional context hook

Replace try/catch around useExplorerSidebarAnimation with a new
useExplorerSidebarAnimationOptional hook that returns null when the
provider is absent.

* chore(lint): rules-of-hooks and exhaustive-deps in sidebar-workspace-list

Replace conditional useMemo calls with plain inline arrays, capture
creatingWorkspaceTimeoutsRef.current in the effect body before cleanup,
and depend on the full input object in armTimers.

* chore(lint): rules-of-hooks in split-container

Hoist useWorkspaceLayoutStore and useMemo calls above the pane-kind
early return so hooks always run in the same order.

* chore(lint): rules-of-hooks in context-menu and dropdown-menu

Inline the resolvedWidthStyle object inside the content useMemo and move
the early return below the hook so it is always invoked in the same order.

* chore(lint): rules-of-hooks in web-desktop-scrollbar

Move thumbRegionStyle and handleStyle useMemo calls above the visibility
early return.

* chore(lint): reduce complexity in types/stream

Split reduceStreamUpdate timeline branch into per-case helpers (tool call and compaction) to clear complexity and max-depth warnings.

* chore(lint): reduce complexity in diff-highlighter

Extract metadata detection, path extraction, hunk parsing, and content-line push into helpers to drop parseDiff complexity below 20.

* chore(lint): exhaustive-deps in composer and use-attachment-preview-url

Memoize githubSearchItems so the picker callbacks don't see a new array
every render, hoist realtimeVoiceButtonStyle above rightContent useMemo
that depends on it, and add missing style dependencies. Tighten
use-attachment-preview-url to read the attachment via a ref while keying
off stable field primitives.

* chore(lint): reduce complexity in tool-call-detail-state

Extract per-detail helpers for hasMeaningfulToolCallDetail to clear complexity warning.

* chore(lint): reduce complexity in agent-grouping

Split groupAgents into partition, project-activity map, active-project, and inactive-date helpers.

* chore(lint): reduce max-depth in voice-runtime

Extract retireFinishedGroup helper to flatten processPlaybackQueue nesting.

* chore(lint): reduce max-depth in use-branch-switcher

Extract maybeRestoreStashForBranch callback to flatten handleBranchSelect nesting.

* chore(lint): exhaustive-deps in components batch

Address exhaustive-deps warnings across file-explorer-pane, file-pane,
git-diff-pane, message, provider-diagnostic-sheet, stream-strategy-web,
terminal-emulator, terminal-pane, and volume-meter. Memoize values that
otherwise changed every render, read non-reactive values through refs
when the effect intentionally tracks a different key, and add missing
dependencies where they were genuinely absent.

* chore(lint): reduce complexity/max-depth in draft-store

Extract collectQueuedMessageAttachmentIds and collectStreamUserImageIds helpers from runAttachmentGc. Extract buildMigratedDraftRecord helper for migratePersistedState.

* chore(lint): reduce complexity in tooltip and workspace-scripts-button

Extract resolveActualSide and resolveAlignedCoordinate helpers in tooltip. Extract resolveScriptIconColor in workspace-scripts-button.

* chore(lint): exhaustive-deps in contexts and hooks batch

Fixes exhaustive-deps warnings across app contexts and hooks by adding
missing deps, stabilizing derived arrays with useMemo, capturing ref values
at effect entry, and using ref pattern where deps were intentionally omitted.

* chore(lint): final hook-rules in workspace-screen, editor-button, stores, e2e

Memoizes derived arrays (availableEditors, terminals) to stabilize
useMemo deps, adds missing deps (normalizedServerId, normalizedWorkspaceId,
explorerToggleStyle, workspaceIdsKey, eventName), and renames the Playwright
fixture callback parameter from `use` to `provide` so eslint-plugin-react-hooks
doesn't conflate it with React's use() hook.

* chore(lint): reduce complexity/max-nested-callbacks in workspace-tabs-store

Extract migrate function body into migrateWorkspaceTabsState top-level
helper with per-loop sub-helpers (migrateUiTabsForKey, mergeExplicitTabOrder,
mergeLegacyTabOrder, migrateFocusedTabIds) and replace the inline IIFE in
ensureTab with buildNextTabsForEnsure helper.

* chore(lint): reduce complexity in session-store and panel-store

Extract isSessionServerInfoUnchanged helper from updateSessionServerInfo
in session-store. In panel-store split migrate body into per-version
migration helpers (migratePanelV2Explorer, migratePanelV3Explorer,
migratePanelExplorerTabByCheckout, migratePanelDesktopFocusMode) and
a top-level migratePanelState function.

* chore(lint): reduce complexity in keyboard-shortcuts and use-keyboard-shortcuts

Split resolveKeyboardShortcut into resolveInitialChordStep and
resolveAdvancingChordStep helpers with a shared buildMatchFromBinding
factory. Split handleAction's large switch into handleDispatchOnlyAction,
handlePayloadAction, handleSettingsToggle, and handleCommandCenterToggle
helpers, with hasPayloadKey type guard replacing inline payload checks.

* chore(lint): reduce complexity in audio recorder web hooks

Extract assertMicrophoneEnvironment helper from useAudioRecorder start
callback. Split useDictationAudioSource stop callback with
disconnectDictationAudioGraph, stopMediaRecorderIfActive,
finalizeRecorderStoppedPromise, and safeDisconnectNode helpers.

* chore(lint): reduce complexity in use-git-actions

* chore(lint): reduce complexity in use-pr-pane-data

* chore(lint): reduce complexity in use-agent-autocomplete

* chore(lint): reduce complexity in use-agent-screen-state-machine

* chore(lint): reduce complexity in session-stream-reducers

* chore(lint): reduce complexity in desktop-updates-section

* chore(lint): reduce complexity in pair-device-section

* chore(lint): reduce complexity in update-callout-source

* chore(lint): reduce complexity in provider-diagnostic-sheet

* chore(lint): disable no-await-in-loop (sequential-by-necessity cases)

Most remaining no-await-in-loop warnings were in legitimate sequential
patterns: polling loops with sleep, streaming/pagination cursors, shared
mutable state, ordered side effects (audio playback, port allocation).
Parallelizing these would change observable behavior. Rather than force
restructures that add complexity without benefit, disable the rule.

* chore(lint): reduce complexity in setup-panel

Extract helpers (resolveAutoExpandIndex, resolveSetupStatusLabel,
resolveCommandLog, buildCommandRowState) and lift the standalone log
view and top-level error banner into dedicated sub-components to drop
SetupPanel below the complexity limit.

* chore(lint): reduce complexity in AssistantMarkdownImage

Extract error-text resolution into a helper so the image component
drops below the cyclomatic complexity threshold.

* chore(lint): reduce complexity in ProjectLeadingVisual

Extract the status-variant dispatch into a ProjectLeadingVisualStatus
sub-component so the outer function stays below the cyclomatic
complexity limit.

* chore(lint): extract ChatAgentContent agent-building helper

* chore(lint): reduce complexity in ChatAgentContent

Extract the chat-agent selector (selectChatAgentState +
resolveChatAgentFromSession), the agent-shape constructor
(buildChatAgentFromState), and the not-found/error/boot view
dispatch (renderChatAgentNonReadyView) into helpers so both the
selector callback and the component function drop below the
cyclomatic complexity limit.

* chore(lint): extract subcomponents to reduce JSX nesting depth

* chore(lint): reduce complexity in app components (status-bar, explorer, setup-dialog, tool-call-details)

* chore(lint): reduce complexity in app hooks/runtime/stores/misc components

* chore(lint): long-tail cleanup (no-async-endpoint-handlers, unicorn rules, unescaped-entities, unassigned-import allowlist)

* chore(lint): simplify agent useMemo deps to match helper signature

* chore(lint): rename ChatService.postMessage to dispatchMessage

* chore(lint): server cleanup (no-shadow, no-useless-*, no-unmodified-loop, control-regex, require-yield)

* chore(lint): app cleanup (jsx-max-depth, jsx-no-new-*-as-prop, complexity, misleading-regex, no-useless-*)

* chore(lint): e2e cleanup (no-unmodified-loop-condition, max-nested-callbacks)

* chore(lint): workspace-screen complexity + no-empty-pattern cleanup

* chore(lint): use Array.from for defensive snapshot iteration

* chore(lint): final mechanical tail (control-regex, max-depth, useless-expressions, examples cleanup)

* chore(lint): reduce agent-stream-view complexity

* chore(lint): reduce resolveAgentModelSelection complexity

* chore(lint): reduce globalSetup complexity

* chore(format): oxfmt draft-store single-line signature

* chore(lint): reduce GitDiffPane complexity

* chore(lint): reduce MessageInput complexity

* chore(lint): reduce Combobox complexity

* fix(server): bound listLinuxWatchDirectories readdir concurrency

Wraps the per-level readdir traversal in listLinuxWatchDirectories with a
module-level p-limit (default 16, tunable via PASEO_LINUX_WATCH_READDIR_CONCURRENCY).
On broad repos, the previous Promise.all over an entire BFS level could issue
an unbounded number of concurrent readdir calls. Behavior is otherwise
identical: still traverses all non-.git directories and swallows per-directory
readdir failures.

* chore(lint): promote oxlint warnings to errors

- package.json: lint/lint:fix now pass --deny-warnings so any warning fails
- .github/workflows/ci.yml: add lint job running npm run lint
- .oxlintrc.json: disable no-empty-pattern for e2e/fixtures.ts
  (Playwright requires the empty object destructure as the first arg)
- packages/app/e2e/fixtures.ts: restore async ({}, provide) signature
- packages/app/e2e/global-setup.ts: oxfmt single-line signature

* fix(test): relay-transport ws mock exports WebSocket named binding

Commit 38efad7d switched relay-transport.ts from default to named import
of ws.WebSocket, but the test mock still only exported default. Updated
vi.mock("ws") to return both default and named WebSocket bindings so the
mocked module matches the new import shape. Classification: test drifted
with production code; test now exports what the production import needs.

* chore(lint): make oxlint error at config level; add Lefthook pre-commit

Replaces --deny-warnings workaround with semantic severity at the config
level, and adds whole-repo pre-commit gates via Lefthook.

- .oxlintrc.json: all enabled categories (correctness, suspicious, perf)
  and intentional rule overrides now use "error" instead of "warn".
  Removed duplicate "require-await" key (last-wins "off" preserved).
- package.json: lint scripts are plain oxlint / oxlint --fix. Adds
  lefthook devDep and "prepare": "lefthook install --force" so hooks
  install on npm install even where core.hooksPath is set globally.
- lefthook.yml: pre-commit runs format:check, lint, typecheck in
  parallel. Whole-repo gates (no glob filters). Block-only, no
  auto-formatting.

* fix: update lockfile signatures and Nix hash

* chore(lint): pre-commit runs formatter in write mode, then lint+typecheck

Switches the pre-commit hook from format:check to format (write) with
stage_fixed: true, so the formatter rewrites files and re-stages them
into the commit. Lint and typecheck then run in parallel against the
formatted tree.

Sequencing uses Lefthook 2.x jobs with a nested group: top-level jobs
run sequentially, so the format job completes before the checks group
starts; within checks, lint and typecheck run in parallel.

CI continues to use format:check, so unformatted code pushed with
--no-verify still fails on the server.

* chore(lint): revert pre-commit to format:check (no stage_fixed)

stage_fixed: true with a whole-repo formatter (oxfmt .) can silently
re-stage the full reformatted file when a developer used git add -p
to stage only a hunk, losing their hunk-level intent. Safer to just
block the commit on unformatted code and let the dev run
`npm run format` themselves.

Reverts to the simple parallel check form: all three commands run
concurrently and are read-only.

* chore(ts7): tsgo compatibility across the monorepo

Preserves tsc 5.9.3 behavior while making TypeScript 7 / tsgo
(@typescript/native-preview 7.0.0-dev.20260423.1) green
package-by-package.

tsconfig:
- server/tsconfig.server.json: drop removed `baseUrl`; rewrite
  `@server/*` paths entry to be relative to the tsconfig.
- website/tsconfig.json: drop removed `baseUrl`; rewrite
  `~/*` paths entry to be relative.
- expo-two-way-audio/tsconfig.json: replace inherited
  moduleResolution=node10 with `bundler`, set `module: esnext`
  (required by `bundler`). Matches Metro/Expo runtime behavior
  and avoids forcing `.js` extensions in source.

source:
- app/e2e/helpers/terminal-probes.ts: WebSocket subclass `send`
  now types its argument as `Parameters<WebSocket["send"]>[0]`
  so the subclass signature tracks the platform type across
  tsc 5.9 and TS 7 libdom.
- relay/src/crypto.ts: tweetnacl setPRNG callback allocates a
  fresh Uint8Array for getRandomValues (owning ArrayBuffer) and
  copies into the caller-provided view. TS 7 libdom requires
  ArrayBufferView<ArrayBuffer> (not ArrayBufferLike).

All 8 packages green under both tsc --noEmit and tsgo --noEmit.

* fix(app): update os-notifications test mocks for addEventListener

Commit 46731a51 switched attachWebClickHandler from notification.onclick
to notification.addEventListener("click", ...) to satisfy the
prefer-add-event-listener lint rule. The test mocks still asserted
the old onclick property, so two specs failed on CI with
"notification.addEventListener is not a function". Update the three
MockNotification classes to record click listeners through
addEventListener and drive them via clickListeners[0] in the
assertions.

* chore: switch typecheck from tsc to tsgo (@typescript/native-preview)

Installs @typescript/native-preview 7.0.0-dev.20260423.1 as a root
devDependency and swaps every per-workspace `typecheck` script from
`tsc --noEmit` to `tsgo --noEmit`. Build scripts still use `tsc`, so
emit behavior is unchanged. Monorepo typecheck drops from ~28s to
~4.7s wall clock (~6x faster).

expo-two-way-audio previously ran `tsc` without --noEmit for its
typecheck step. tsgo is stricter about rootDir on emit, so the
typecheck script explicitly passes --noEmit here too.

* fix: update lockfile signatures and Nix hash

* fix(app): commit *.css module declaration for CI typecheck

expo generates expo-env.d.ts (which references expo/types where
*.css is declared) locally, but the file is gitignored. CI does a
fresh checkout + install and doesn't run expo, so the declaration
is missing and tsgo rejects the side-effect import
'@xterm/xterm/css/xterm.css' with TS2882. Add a committed
packages/app/global.d.ts with 'declare module "*.css"' so typecheck
is self-contained.

tsc tolerated the missing declaration more loosely; tsgo is stricter.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-24 12:26:10 +08:00
Mohamed Boudra
5b2f3fd830 chore: fix unresolved imports and tighten knip cross-workspace entries
Delete orphaned voice-mcp-agent-smoke.ts (voice-mcp-bridge was removed
in cff41d78). Fix model-catalog.e2e.test.ts import path. Extend knip
server entries to cover shared/client cross-workspace imports.
2026-04-23 22:41:27 +07:00
Mohamed Boudra
2dd8c27573 chore: delete unused files flagged by knip
38 files removed across app and server (verified unreferenced via
grep + typecheck). Includes dead components, hooks, stores, utils,
POC scripts, an empty orchestrator stub, stale playwright configs,
and a duplicate workspace-registry test-helpers module.

knip config tightened: added babel.config.js, native platform
extensions, and test-stubs/ to app entry patterns.
2026-04-23 22:29:49 +07:00
github-actions[bot]
dc92863c35 fix: update lockfile signatures and Nix hash 2026-04-23 15:26:07 +00:00
Mohamed Boudra
ba9ed8b995 chore: remove unused deps and declare missing ones
Knip-driven cleanup across all workspaces:

Removed (verified zero imports):
- root: @anthropic-ai/claude-agent-sdk, @modelcontextprotocol/sdk,
  expo, react, react-native, react-dom (duplicates of workspace deps)
- server: @ai-sdk/openai, @deepgram/sdk, express-basic-auth,
  fast-uri, mnemonic-id, tiny-invariant, playwright
- app: @expo/vector-icons, @getpaseo/server,
  @react-navigation/bottom-tabs, @react-navigation/elements,
  base64-js, react-native-css, expo-av, expo-font, expo-symbols,
  expo-web-browser, react-native-permissions,
  react-native-popover-view, react-native-securerandom
- cli: @getpaseo/relay
- desktop: @getpaseo/cli
- relay: @cloudflare/workers-types
- website: tailwindcss (provided by @tailwindcss/vite)

Declared (previously hoisted, now explicit):
- root: @types/ws, playwright, ws (used by scripts/)
- server: @mariozechner/pi-ai, @mariozechner/pi-agent-core
- app: expo-asset, qrcode, @types/qrcode, tiny-invariant,
  @xterm/headless, dotenv, jsdom
- cli: vitest
- desktop: vitest

Also removes the stray `/app.json` (obsolete, overridden by
packages/app/app.config.js).
2026-04-23 22:20:33 +07:00
Mohamed Boudra
2f381a76ed chore: add knip config for unused-code detection
Workspace-aware entry points plus ignore lists for script-only tools
and tools knip's binary resolution misses. Adds `npm run knip`.
2026-04-23 22:13:12 +07:00
Mohamed Boudra
cda3d1d46c chore: apply oxlint fix-suggestions (unused imports + useless fragments)
Scoped to only `no-unused-vars` and `react/jsx-no-useless-fragment` —
the safe subset of --fix-suggestions. Primary changes:

- Remove unused named imports from statements
- Remove unused default/type imports entirely
- Collapse `<><Single/></>` fragments to `<Single/>`

Verified clean: typecheck + format + lint all green.
2026-04-23 21:55:25 +07:00
Mohamed Boudra
731c570b25 fix(types): repair typecheck after oxlint safe autofix
The safe --fix introduced three type issues:

1. unicorn/prefer-set-has rewrote array literals to `new Set(...)`
   without updating the `T[]` type annotation — broken assignment.
   Fix type to `Set<T>` in logger.ts and agent-manager.ts.

2. unicorn/no-array-reverse changed `.reverse()` → `.toReversed()`
   (ES2023). Bump `lib` to ES2023 in tsconfig.base.json and
   tsconfig.server.json. Target stays at ES2020 — Node 20 supports
   the methods natively, no downlevel needed.

3. preserve-caught-error added `new Error(msg, { cause })` (ES2022),
   also covered by the lib bump.
2026-04-23 21:55:19 +07:00
Mohamed Boudra
177153e5e8 chore: apply oxlint autofix (safe level)
Auto-fixed 102 warnings (6040 → 5938) across 57 files. Main categories:
- preserve-caught-error: add { cause: error } to re-thrown errors
- unicorn/no-array-reverse: .reverse() → .toReversed()
- unicorn/no-useless-fallback-in-spread: drop unnecessary ?? {} in spreads
- oxc/no-map-spread: rewrite object spread in .map() to Object.assign
- react-hooks/exhaustive-deps: trim module-level constants from dep arrays

Ran npm run format after to clean up whitespace from lint mutations.
2026-04-23 21:47:36 +07:00
Mohamed Boudra
9c88850de0 chore: replace biome with oxc (oxfmt + oxlint)
- Remove @biomejs/biome from root and packages/expo-two-way-audio
- Delete biome.json
- Add .oxfmtrc.json (migrated from biome via oxfmt --migrate=biome)
- Add .oxlintrc.json with curated rule set: react, react-perf,
  react-hooks, typescript, unicorn, oxc, import, promise
  - Rules target functional gotchas, untyped code, perf issues,
    deep JSX, nested ternaries, complexity, unused code
  - Noisy stylistic rules explicitly disabled
- Add npm scripts: lint, lint:fix (oxfmt replaces format/format:check)
- Update CI to run oxfmt --check

Lint is wired up but not enforced — all rules at warn. Baseline:
6040 warnings, 0 errors.
2026-04-23 21:44:56 +07:00
Mohamed Boudra
ce4b0d54c7 chore: reformat codebase with oxfmt
Apply prettier-compatible formatting across the repo to match the
incoming oxfmt configuration. Mechanical reformat only — no logic
changes. Covers YAML quote normalization, package.json key sorting,
Markdown/TOML formatting, and minor TS whitespace tweaks.
2026-04-23 21:44:48 +07:00
Mohamed Boudra
9b25f8fc36 Auto-submit iOS builds for App Store review via fastlane
Adds a fastlane lane and an EAS Workflow custom job that runs after
submit_ios. Picks up the latest TestFlight build, waits for Apple
processing, and submits for review with auto-release on approval.

Uses ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 secrets from the EAS
production environment.
2026-04-23 21:36:35 +07:00
Mohamed Boudra
26e6d97e81 Auto-submit Android to production track
Was uploading to internal track as draft, requiring manual release in
Play Console. Now rolls out to 100% on production after Google review.
2026-04-23 21:21:58 +07:00
Mohamed Boudra
80102753e1 Make worktree archive optimistic 2026-04-23 21:16:23 +07:00
github-actions[bot]
75b8ae640c fix: update lockfile signatures and Nix hash 2026-04-23 13:30:33 +00:00
Mohamed Boudra
e521e1f79d chore(release): cut 0.1.62 2026-04-23 20:28:53 +07:00
Mohamed Boudra
a62c7471d2 docs: add 0.1.62 changelog entry 2026-04-23 20:27:40 +07:00
Mohamed Boudra
3edf812578 Fix PR worktree status lookup 2026-04-23 20:10:48 +07:00
Mohamed Boudra
dfc8223bcc Make workspace git registration nonblocking (#542) 2026-04-23 18:21:59 +08:00
Mohamed Boudra
083cd34db4 Merge branch 'scan-cleanup-opportunities' 2026-04-23 15:28:34 +07:00
Mohamed Boudra
9abc0217e9 refactor: extract types and simplify activity grouping 2026-04-23 15:28:25 +07:00
Mohamed Boudra
576cc0b668 Add sidebar callout surface 2026-04-23 15:19:40 +07:00
Mohamed Boudra
e21ef2fc9b fix: prevent old release website redeploys 2026-04-23 10:17:37 +07:00
Mohamed Boudra
c8b61f7ddc Show release contributors from synced notes 2026-04-23 09:17:45 +07:00
Mohamed Boudra
9c5974735e Fix desktop CLI environment boundary 2026-04-23 07:08:24 +07:00
Mohamed Boudra
f65f82bac9 fix(release): keep changelog notes authoritative 2026-04-23 06:43:18 +07:00
github-actions[bot]
9a35f35bab fix: update lockfile signatures and Nix hash 2026-04-22 23:34:38 +00:00
Mohamed Boudra
8cc7e8951c chore(release): cut 0.1.61 2026-04-23 06:33:09 +07:00
Mohamed Boudra
38a41a71ff docs: promote 0.1.61 changelog 2026-04-23 06:32:11 +07:00
Mohamed Boudra
ce6393a933 fix(app): avoid hashing image preview payloads 2026-04-22 23:15:28 +07:00
github-actions[bot]
339a0b6b43 fix: update lockfile signatures and Nix hash 2026-04-22 15:39:01 +00:00
Mohamed Boudra
1f7b4eb10f chore(release): cut 0.1.61-beta.1 2026-04-22 22:37:42 +07:00
Mohamed Boudra
3560fb58bb docs: add 0.1.61-beta.1 changelog entry 2026-04-22 22:36:27 +07:00
Mohamed Boudra
4140e640fe feat(app): store assistant image previews as assets 2026-04-22 22:28:56 +07:00
Mohamed Boudra
434b28fc8e Add Providers docs page
Splits first-class and custom provider docs out of the Configuration
page onto /docs/providers. Introduces the first-class providers
(claude, codex, opencode, copilot, pi) and covers extending a provider,
Z.AI and Qwen plans, multiple profiles, custom binaries, ACP agents,
additionalModels, and disabling. Points to docs/CUSTOM-PROVIDERS.md on
GitHub as the source of truth.
2026-04-22 22:24:12 +07:00
Mohamed Boudra
1f8f8e4036 Reduce workspace rerenders during explorer resize 2026-04-22 22:18:11 +07:00
Mohamed Boudra
b133285f6d Add additive custom provider models 2026-04-22 22:16:54 +07:00
Mohamed Boudra
4d5889ff38 Load Pi extensions for model discovery 2026-04-22 22:12:49 +07:00
github-actions[bot]
de57f301f9 fix: update lockfile signatures and Nix hash 2026-04-22 13:40:36 +00:00
Mohamed Boudra
95c773ce6d chore(release): cut 0.1.60 2026-04-22 20:38:54 +07:00
Mohamed Boudra
c6680b3001 fix(release): preserve 0.1.60 schema compatibility 2026-04-22 20:37:19 +07:00
Mohamed Boudra
484e453401 Fix timeline catch-up for projected overlap 2026-04-22 20:21:26 +07:00
Mohamed Boudra
4b867c2f41 Fix mobile tool call expansion while streaming 2026-04-22 20:05:41 +07:00
Mohamed Boudra
368ce0339a Lazy-load GitHub picker queries 2026-04-22 20:00:43 +07:00
Mohamed Boudra
8e63274ce6 fix(server): unify worktree archive paths 2026-04-22 19:59:00 +07:00
Mohamed Boudra
d80c524e0e fix(app): restore archive and composer CI checks 2026-04-22 19:16:28 +07:00
Mohamed Boudra
89efb007f9 Fix Codex replacement turn idle flash 2026-04-22 18:54:20 +07:00
Mohamed Boudra
407b4b68b0 Recover branch during rebase 2026-04-22 18:35:01 +07:00
Mohamed Boudra
637109eab0 Fix workspace hover card safe zone 2026-04-22 18:22:20 +07:00
github-actions[bot]
3a0161d051 fix: update lockfile signatures and Nix hash 2026-04-22 11:08:00 +00:00
Mohamed Boudra
027b3839fc chore(release): cut 0.1.60-beta.2 2026-04-22 18:06:55 +07:00
Mohamed Boudra
c3589bdc84 chore: refresh lockfile metadata 2026-04-22 18:05:56 +07:00
Mohamed Boudra
a5e6cc2d2b docs: note beta sanity fixes 2026-04-22 18:04:32 +07:00
Mohamed Boudra
713c90ea42 fix(server): restore release-blocking lifecycle checks 2026-04-22 18:03:37 +07:00
Mohamed Boudra
e51fdada51 docs: update changelog for 0.1.60-beta.2 2026-04-22 17:58:50 +07:00
Mohamed Boudra
dd98ae9359 fix(server): checkout fork PRs into owner branches 2026-04-22 17:53:53 +07:00
Mohamed Boudra
274a7fd217 fix(server): preserve dev runner signal handling 2026-04-22 17:53:53 +07:00
Mohamed Boudra
a582fccef0 fix(app): clear new workspace drafts after submit 2026-04-22 17:53:53 +07:00
Mohamed Boudra
019bf65790 Fix running-agent replacement lifecycle 2026-04-22 17:53:53 +07:00
Mohamed Boudra
9218f70df3 Remove leftover app debug logging 2026-04-22 17:53:53 +07:00
therainisme
fd05bf8bff fix(server): preserve Codex fast mode after plan approval
Codex was clearing fast mode after approving a plan.

This keeps fast mode and plan mode independent. Approving a plan still exits plan collaboration mode, and the follow-up implementation turn now keeps serviceTier=fast when fast mode was already enabled.

Verified locally:
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/providers/codex-app-server-agent.test.ts --bail=1
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/agent-manager.test.ts --bail=1
- npm run typecheck
2026-04-22 18:51:57 +08:00
Mohamed Boudra
9e3d8f0ec5 fix(cli): send version during daemon handshake 2026-04-22 16:19:51 +07:00
Mohamed Boudra
43b9123c5c fix(app): restore workspace tab focus on refresh 2026-04-22 16:19:43 +07:00
Mohamed Boudra
b6afc66131 fix(app): avoid settings header offset 2026-04-22 16:19:31 +07:00
Mohamed Boudra
7993dc02dc Add working indicator top spacing 2026-04-22 16:06:34 +07:00
Mohamed Boudra
6601db2673 Fix stream action spacing branches 2026-04-22 15:49:00 +07:00
Mohamed Boudra
e4badd95f4 Restore stream auxiliary spacing 2026-04-22 15:44:29 +07:00
Mohamed Boudra
02fc354686 Gate desktop daemon dev providers in production 2026-04-22 15:42:36 +07:00
Mohamed Boudra
aef54d5180 Optimize agent stream rendering 2026-04-22 15:38:53 +07:00
Mohamed Boudra
3c5bfd6e3d Stabilize sidebar workspace press handler 2026-04-22 14:14:34 +07:00
Mohamed Boudra
f422dabf1e Reduce redundant runtime updates 2026-04-22 14:03:59 +07:00
Mohamed Boudra
2706d5e934 fix(app): avoid duplicate bootstrap connection client 2026-04-22 13:26:45 +07:00
Mohamed Boudra
6b210df471 fix(app): stabilize startup connection routing 2026-04-22 13:11:41 +07:00
Mohamed Boudra
9a39ba4be0 Default agent ls to active scope 2026-04-22 12:52:52 +07:00
Mohamed Boudra
fc86ce2a5f Tighten Escape agent interrupt shortcut 2026-04-22 12:52:47 +07:00
Mohamed Boudra
6edf19c7c6 fix(app): streamline startup loading states 2026-04-22 12:13:23 +07:00
Mohamed Boudra
80c319dc49 Avoid git refresh for agent status updates 2026-04-22 12:07:57 +07:00
Mohamed Boudra
77c8c91ab5 Fix dev server Ctrl-C shutdown wait 2026-04-22 12:03:46 +07:00
Mohamed Boudra
6503245493 Avoid GitHub self-heal polling without remote 2026-04-22 11:21:37 +07:00
Mohamed Boudra
184902dd0d Merge branch 'perf/startup-rpc-metrics' 2026-04-22 11:03:41 +07:00
Mohamed Boudra
42cdabdf57 Fix optimistic workspace creation on Android 2026-04-22 11:03:35 +07:00
Mohamed Boudra
247983ee87 Improve startup provider snapshots and dev harness 2026-04-22 10:52:19 +07:00
Mohamed Boudra
7b9cc3d423 fix(app/e2e): use xterm buffer for alt-screen exit sync 2026-04-21 23:28:35 +07:00
github-actions[bot]
8ee3c6ae1d fix: update lockfile signatures and Nix hash 2026-04-21 16:06:25 +00:00
Mohamed Boudra
244c40a62f test(app): restore e2e playwright suite to green
- Fake gh stub now handles `gh pr view --json <fields>` by exiting 1
  with "no pull requests found for branch", matching the daemon's
  expected no-current-PR path; resolveCurrentPullRequestView no longer
  cascades failures across the suite.
- Rewrite the two post-setup script tests in workspace-setup-streaming
  to exercise explicit launch paths (UI scripts menu + daemon RPC) —
  39db3ea5 intentionally removed automatic post-setup script spawning.
- Harden terminal-alternate-screen synchronization: wait for the probe
  to observe alt-enter and alt-exit plus the final normal-screen marker
  before asserting, so the echoed command no longer races the alt-screen
  transition.
2026-04-21 23:04:57 +07:00
Mohamed Boudra
3fe63d5a2c fix(app,server): discover server-cached setup snapshots across sessions
807cce6a hides the "Show setup" action when there is no workspace setup
snapshot. That broke the case where setup genuinely ran on a different
WebSocket session than the one the browser reconnected with: the snapshot
was cached per-Session, so discovery returned null and the action stayed
hidden.

Share the setup snapshot cache at the websocket-server level and inject
it into every Session. On focus, the workspace screen now requests
fetchWorkspaceSetupStatus when it has no local snapshot; workspaces with
no setup history still return null, preserving 807cce6a's intent.
2026-04-21 23:04:47 +07:00
Mohamed Boudra
1582028902 Cache available editor discovery 2026-04-21 22:39:46 +07:00
Mohamed Boudra
7e7cc1ad56 Contain malformed ACP stdout logging 2026-04-21 22:38:12 +07:00
Mohamed Boudra
27ddfb1ea7 Fix workspace diff stats for behind branches 2026-04-21 22:03:24 +07:00
Mohamed Boudra
807cce6ad9 Hide setup action when no setup ran 2026-04-21 22:03:06 +07:00
Mohamed Boudra
73b28a7f60 Fix notification clicks preserving workspace deck 2026-04-21 21:57:15 +07:00
Mohamed Boudra
8dad423635 fix(app): remove pointer cursor on user message bubbles
The Pressable wrapping user messages is only used for hover detection,
so default to the auto cursor instead of pointer on web.
2026-04-21 21:54:45 +07:00
Mohamed Boudra
abaa29e73c ci(app): install playwright browsers before app vitest run
packages/app's vitest config has a browser project backed by playwright
+ chromium; the app-tests job needs the browsers installed the same way
the playwright job already does.
2026-04-21 21:53:23 +07:00
Mohamed Boudra
ae2a301cb6 fix(server): reflect terminalId default in workspace script schema test
workspace script schema defaults terminalId to null; include it in the
expected shape of the test's toEqual assertion.
2026-04-21 21:33:39 +07:00
Mohamed Boudra
006d57d546 fix(server): align MCP and model-resolver tests with current API
mcp-server tests invoked registered tools via .handler, but the MCP
SDK exposes the callable as .callback. model-resolver now passes
force: false to fetchModels; widen the spy matcher to match.
2026-04-21 21:29:23 +07:00
github-actions[bot]
a201e5a853 fix: update lockfile signatures and Nix hash 2026-04-21 13:50:39 +00:00
Mohamed Boudra
c402db8b1c test: add manual terminal perf harness 2026-04-21 20:47:28 +07:00
Mohamed Boudra
6a03873d81 fix: smooth terminal streaming 2026-04-21 20:47:17 +07:00
Mohamed Boudra
39db3ea5ff refactor: simplify worktree script spawning 2026-04-21 20:46:54 +07:00
Mohamed Boudra
79bf5ac3c7 test: add app browser coverage 2026-04-21 20:46:49 +07:00
Mohamed Boudra
051507717c Fix sticky workspace hover card 2026-04-21 18:17:39 +07:00
Mohamed Boudra
f3cb19529c Harden agent refresh rescue path 2026-04-21 17:22:50 +07:00
Mohamed Boudra
cea97c83b3 Reuse terminals for non-service scripts and track lifecycle via OSC 633
Scripts launched from the UI now run in a dedicated terminal per script
(titled after the script) and reuse the same terminal across re-runs.
Script lifecycle is driven by a new onCommandFinished signal sourced from
OSC 633 command-finished events emitted by the zsh shell integration, so
foreground script exit updates status without killing the terminal.
Script payloads now carry terminalId so the workspace can surface the
associated tab from the scripts dropdown.
2026-04-21 17:12:53 +07:00
Mohamed Boudra
3e2faab7f8 Fix committed diff excluding dirty worktree 2026-04-21 15:02:20 +07:00
835 changed files with 77639 additions and 55798 deletions

View File

@@ -51,7 +51,7 @@ jobs:
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--generate-notes
--notes ""
)
if [[ "$IS_PRERELEASE" == "true" ]]; then

View File

@@ -15,14 +15,30 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Check formatting
run: npx biome format .
run: npx oxfmt --check .
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Lint
run: npm run lint
typecheck:
runs-on: ubuntu-latest
@@ -31,8 +47,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -58,8 +74,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
@@ -88,8 +104,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -126,12 +142,15 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -145,8 +164,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -188,8 +207,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -207,8 +226,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
@@ -222,6 +241,6 @@ jobs:
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
PASEO_DICTATION_ENABLED: '0'
PASEO_VOICE_MODE_ENABLED: '0'
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"

View File

@@ -3,10 +3,10 @@ name: Deploy App
on:
push:
tags:
- 'v*'
- '!v*-beta.*'
- 'app-v*'
- '!app-v*-beta.*'
- "v*"
- "!v*-beta.*"
- "app-v*"
- "!app-v*-beta.*"
workflow_dispatch:
jobs:
@@ -18,10 +18,10 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install dependencies
run: npm install --workspace=@getpaseo/app --include-workspace-root

View File

@@ -4,8 +4,8 @@ on:
push:
branches: [main]
paths:
- 'packages/relay/**'
- '.github/workflows/deploy-relay.yml'
- "packages/relay/**"
- ".github/workflows/deploy-relay.yml"
workflow_dispatch:
jobs:
@@ -17,8 +17,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/relay --include-workspace-root
@@ -30,4 +30,3 @@ jobs:
run: cd packages/relay && npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -4,13 +4,14 @@ on:
push:
branches: [main]
paths:
- 'packages/website/**'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
- "CHANGELOG.md"
- "packages/website/**"
- "package.json"
- "package-lock.json"
- "patches/**"
- ".github/workflows/deploy-website.yml"
release:
types: [published, edited]
types: [published]
workflow_dispatch:
jobs:
@@ -23,8 +24,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/website --include-workspace-root

View File

@@ -3,21 +3,21 @@ name: Desktop Release
on:
push:
tags:
- 'v*'
- 'desktop-v*'
- 'desktop-macos-v*'
- 'desktop-linux-v*'
- 'desktop-windows-v*'
- "v*"
- "desktop-v*"
- "desktop-macos-v*"
- "desktop-linux-v*"
- "desktop-windows-v*"
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build (e.g. v0.1.0)'
description: "Existing tag to build (e.g. v0.1.0)"
required: true
type: string
platform:
description: 'Optional desktop platform to build.'
description: "Optional desktop platform to build."
required: false
default: 'all'
default: "all"
type: choice
options:
- all
@@ -31,8 +31,8 @@ concurrency:
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
DESKTOP_WORKSPACE: '@getpaseo/desktop'
DESKTOP_PACKAGE_PATH: 'packages/desktop'
DESKTOP_WORKSPACE: "@getpaseo/desktop"
DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs:
create-release:
@@ -65,7 +65,7 @@ jobs:
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
--notes "" \
$prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
}
@@ -100,11 +100,11 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
@@ -278,11 +278,11 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
@@ -346,11 +346,11 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci

View File

@@ -4,12 +4,12 @@ on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
- "package.json"
- "package-lock.json"
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
- "package.json"
- "package-lock.json"
permissions:
contents: write
@@ -25,8 +25,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:

View File

@@ -4,27 +4,27 @@ on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
permissions:
contents: read

View File

@@ -54,7 +54,9 @@ jobs:
fi
create_if_missing="false"
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
if [[ "$EVENT_NAME" = "push" && "$REF" == refs/tags/v* ]]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
fi

View File

@@ -4,17 +4,17 @@ on:
push:
branches: [main]
paths:
- 'packages/server/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/server-ci.yml'
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
pull_request:
branches: [main]
paths:
- 'packages/server/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/server-ci.yml'
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
jobs:
test:
@@ -27,8 +27,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main

15
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"useTabs": false,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": false,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"semi": true,
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock"]
}

89
.oxlintrc.json Normal file
View File

@@ -0,0 +1,89 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "error"
},
"rules": {
"react/react-in-jsx-scope": "off",
"no-await-in-loop": "off",
"no-unused-expressions": "error",
"no-useless-catch": "error",
"preserve-caught-error": "error",
"require-await": "off",
"no-async-promise-executor": "error",
"no-useless-escape": "error",
"no-empty-pattern": "error",
"no-self-assign": "error",
"no-shadow": "error",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-sort": "off",
"unicorn/no-useless-spread": "error",
"unicorn/no-useless-fallback-in-spread": "error",
"unicorn/no-new-array": "error",
"unicorn/no-empty-file": "error",
"promise/always-return": "error",
"promise/no-multiple-resolved": "error",
"react/no-array-index-key": "error",
"react/jsx-no-useless-fragment": "error",
"react/jsx-no-constructed-context-values": "error",
"react/no-unescaped-entities": "error",
"react/button-has-type": "error",
"react/jsx-max-depth": ["error", { "max": 6 }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react-perf/jsx-no-new-array-as-prop": "error",
"react-perf/jsx-no-new-function-as-prop": "error",
"react-perf/jsx-no-new-object-as-prop": "error",
"react-perf/jsx-no-jsx-as-prop": "error",
"oxc/no-map-spread": "error",
"oxc/no-async-endpoint-handlers": "error",
"oxc/only-used-in-recursion": "error",
"typescript/no-explicit-any": "error",
"typescript/prefer-as-const": "error",
"typescript/no-this-alias": "error",
"typescript/consistent-type-definitions": ["error", "interface"],
"import/no-unassigned-import": [
"error",
{
"allow": [
"**/*.css",
"**/expo-router/entry",
"**/event-target-polyfill",
"**/dotenv/config",
"**/react-native",
"**/@/styles/unistyles",
"**/src/styles/unistyles",
"**/@/test/window-local-storage"
]
}
],
"no-nested-ternary": "error",
"no-unneeded-ternary": "error",
"complexity": ["error", { "max": 20 }],
"max-depth": ["error", { "max": 4 }],
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["**/e2e/fixtures.ts"],
"rules": {
"no-empty-pattern": "off"
}
}
]
}

View File

@@ -1,26 +1,116 @@
# Changelog
## 0.1.60-beta.1 - 2026-04-20
## 0.1.63-beta.1 - 2026-04-24
### Improved
- Archiving a worktree is instant instead of waiting for the backend to confirm.
- Terminal sessions recover cleanly after rendering hiccups.
- Agent timelines and git diff lists no longer jump around while loading or streaming.
### Fixed
- File links with line numbers (like `foo.ts:42`) open correctly from assistant messages.
## 0.1.62 - 2026-04-23
### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
- Sidebar warning when your app and daemon versions drift apart, with a shortcut to settings.
### Improved
- Workspaces appear in the sidebar immediately on startup instead of waiting for git registration.
### Fixed
- Pull request status resolves correctly for PRs opened from forks.
- Installing the paseo CLI from the macOS desktop app now works in packaged builds.
- Agents launched from the desktop app no longer inherit Electron-only environment variables.
## 0.1.61 - 2026-04-23
### Added
- `additionalModels` option in provider config lets you add or relabel models without replacing the full list — entries merge with runtime-discovered models (ACP) or your static `models` list. See the [Providers docs](https://paseo.sh/docs/providers).
- New [Providers docs page](https://paseo.sh/docs/providers) covering first-class providers and every custom provider config pattern in one place.
### Improved
- Pi loads your installed extensions on startup so their models show up in the model picker.
- Resizing the explorer sidebar no longer rerenders the rest of the workspace.
- Images in assistant messages (both file paths and inline data URLs) persist as local attachments and open in the file pane.
## 0.1.60 - 2026-04-22
### Added
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
- Launch scripts and services for a worktree directly from the workspace header.
- New Setup tab in every workspace showing setup, teardown, and script progress live.
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
- Pull request pane in the workspace sidebar.
- Redesigned Settings screen with modular section navigation.
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
- New-workspace picker ranks branches by recency with fast search.
### Improved
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
- Agent streams render more smoothly during heavy tool output.
- App startup routes through a stable connection and lands on the right screen without flicker.
- Provider refresh is reliable and no longer stalls on transient failures.
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
- In-app notifications route to whichever surface you're actually looking at.
- Keyboard shortcuts keep working while Settings is open.
- Escape reliably interrupts the active agent.
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
- `paseo ls` defaults to active agents; pass `-a` to include archived.
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
### Fixed
- Composer textarea shrinks back down after sending on web.
- New workspace drafts clear after submit instead of sticking around.
- Replacing a running agent cleans up the previous one without leaving it behind.
- Agent notifications no longer get swallowed by a backgrounded focused client.
- Removed workspace folders disappear from the workspace list again.
- Codex keeps fast mode after you approve a plan. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))
- Workspace tab focus is preserved across page refreshes.
- Settings screen no longer pushes its header down with extra spacing.
- Branch switcher title no longer overflows on narrow rows.
- iOS image picker no longer leaves the screen unresponsive after cancelling.
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
- Tool call blocks expand correctly on mobile while an agent is still streaming.
- Timeline no longer stutters when catch-up and projected ranges overlap.
- Codex no longer flashes idle when a replacement turn is in progress.
- Branch state recovers correctly when a rebase is in progress.
- Workspace hover card no longer clips near screen edges.
## 0.1.59 - 2026-04-16
### Added
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
- Extra High reasoning effort for Opus 4.7, between High and Max.
## 0.1.58 - 2026-04-16
### Added
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
- `disallowedTools` option in provider config to block specific tools from an agent.
### Improved
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
@@ -30,6 +120,7 @@
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
### Fixed
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
@@ -43,12 +134,14 @@
## 0.1.56 - 2026-04-14
### Fixed
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
- A single problematic project can no longer prevent the rest of your workspaces from loading.
## 0.1.55 - 2026-04-14
### Added
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
- Choose provider and model when creating scheduled agents.
@@ -56,12 +149,14 @@
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
### Improved
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
- iPad and tablet layouts work correctly across all screen sizes.
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
### Fixed
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
- Workspace navigation updates correctly when switching between workspaces in the same project.
@@ -71,13 +166,16 @@
## 0.1.54 - 2026-04-12
### Added
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
### Improved
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
### Fixed
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
@@ -85,6 +183,7 @@
## 0.1.53 - 2026-04-12
### Added
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
@@ -93,6 +192,7 @@
- Keyboard shortcut to cycle themes.
### Improved
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
- Streaming markdown on mobile renders significantly faster.
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
@@ -100,6 +200,7 @@
- Relay and pairing URLs are stripped from daemon logs.
### Fixed
- Closed agent tabs no longer reappear after reconnecting.
- Desktop notification badge counts match across all workspaces.
- Host switcher status syncs correctly when switching between hosts.
@@ -107,11 +208,13 @@
## 0.1.52 - 2026-04-10
### Added
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
### Fixed
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
@@ -124,11 +227,13 @@
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
@@ -140,6 +245,7 @@
## 0.1.50 - 2026-04-07
### Added
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
@@ -148,6 +254,7 @@
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved
- Workspaces load instantly on connect instead of waiting for a full sync.
- File explorer and diff pane remember which folders are expanded when you switch tabs.
- Closing a workspace tab is now instant.
@@ -155,6 +262,7 @@
- Reload agent moved away from the close button to prevent accidental taps.
### Fixed
- Voice mode no longer drifts into false speech detection during long sessions.
- Garbled overlapping text on plan cards.
- Changes pane could show stale diffs when working with git worktrees.
@@ -167,6 +275,7 @@
## 0.1.49 - 2026-04-07
### Fixed
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker only shows models from the agent's own provider, not every provider on the server.
- Model lists stay consistent regardless of which screen you open first.
@@ -174,17 +283,20 @@
## 0.1.48 - 2026-04-05
### Added
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
### Improved
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
- Removed noisy agent lifecycle toasts that fired on every state change.
### Fixed
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
- Input field on running agent screens now correctly receives keyboard focus.
- Mobile model selector alignment and sizing.
@@ -192,6 +304,7 @@
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
@@ -200,6 +313,7 @@
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
@@ -208,6 +322,7 @@
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
@@ -220,11 +335,13 @@
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
@@ -241,6 +358,7 @@
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
@@ -250,16 +368,19 @@
## 0.1.43 - 2026-04-02
### Added
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
- Searchable model favorites — quickly find and pin preferred models.
- Slash command support for OpenCode agents.
### Improved
- Refined model selector UX with better mobile sheet behavior.
- Workspace status now uses amber alert styling for "needs input" state.
- Themed scrollbar on message input for consistent styling.
### Fixed
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
- Shell arguments with spaces are now properly quoted on Windows.
- Claude models with 1M context support are now correctly reported.
@@ -267,11 +388,13 @@
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
@@ -283,18 +406,22 @@
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
@@ -305,61 +432,73 @@
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
@@ -368,6 +507,7 @@
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
@@ -384,6 +524,7 @@
## 0.1.33 - 2026-03-23
### Fixed
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
@@ -392,6 +533,7 @@
## 0.1.32 - 2026-03-23
### Added
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
- Added line numbers and word-wrap toggle to file previews.
@@ -401,6 +543,7 @@
- Added status bar tooltips for project and agent status.
### Improved
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
@@ -415,6 +558,7 @@
- Better error messages from the daemon — RPC errors now include the actual underlying details.
### Fixed
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
@@ -423,16 +567,19 @@
## 0.1.30 - 2026-03-19
### Added
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
- Added a combined model selector and agent mode visuals across key UI surfaces.
- Added Open Graph metadata improvements for richer website sharing previews.
### Improved
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
- Improved project picker path display and general workspace UI polish.
### Fixed
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
@@ -440,30 +587,36 @@
## 0.1.28 - 2026-03-15
### Added
- Added OpenCode build and plan modes.
- Added website landing pages for Claude Code, Codex, and OpenCode.
### Improved
- Improved the git action menu for more reliable repository actions.
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
- Updated the website hero copy and added a sponsor callout section.
### Fixed
- Fixed assistant file links so they open the correct workspace files from chat.
## 0.1.27 - 2026-03-13
### Added
- Added voice runtime with new audio engine architecture for voice interactions.
- Added Grep tool support in Claude tool-call mapping.
- Added ability to open workspace files directly from agent chat messages.
- Added desktop notifications via a custom native bridge.
### Improved
- Improved image picker, markdown rendering, and UI interactions.
- Improved shell environment detection using shell-env.
### Fixed
- Fixed platform-specific markdown link rendering.
- Fixed Linux AppImage CLI resource paths.
- Fixed Codex replacement stream being killed by stale turn notifications.
@@ -471,15 +624,18 @@
## 0.1.26 - 2026-03-12
### Added
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
- Added Windows support with improved cross-platform shell execution.
### Improved
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
- Added a Discord link to the website navigation.
### Fixed
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
- Fixed the home route to hide browser chrome when appropriate.
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
@@ -489,61 +645,79 @@
## 0.1.25 - 2026-03-11
### Fixed
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
## 0.1.24 - 2026-03-10
### Improved
- Improved command center keyboard navigation and new tab shortcut.
- Simplified desktop release pipeline for faster and more reliable builds.
## 0.1.21 - 2026-03-10
### Improved
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
### Fixed
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
## 0.1.20 - 2026-03-10
### Added
- Added workspace sidebar git actions with quick diff stats and archive controls.
- Added refreshed website downloads and homepage presentation for desktop installs.
### Improved
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
### Fixed
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
## 0.1.19 - 2026-03-09
### Added
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
## 0.1.18 - 2026-03-06
### Added
- Added a desktop `Mod+W` shortcut to close the current tab.
### Improved
- New and newly selected terminals now take focus automatically so you can type immediately.
- Kept newly created workspaces and projects in a more stable order in the sidebar.
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
- Updated the website desktop download link to use the universal macOS DMG.
### Fixed
- Restored automatic agent metadata generation for Claude runs.
## 0.1.17 - 2026-03-06
### Added
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.
- Keyboard shortcuts for workspace and tab navigation, with shortcut badges in the sidebar.
- Workspace-level archive actions with improved worktree archiving flow and context menu support.
- In-chat task notifications rendered as synthetic tool-call events for clearer status tracking.
### Improved
- Desktop builds now ship as a universal macOS binary (Apple Silicon + Intel).
- More reliable workspace routing and tab identity handling across refreshes and deep links.
- Better sidebar drag-and-drop behavior with explicit drag handles and nested list interactions.
@@ -551,6 +725,7 @@
- Stronger provider error surfacing and updated Claude model/runtime handling.
### Fixed
- Fixed orphan workspace runs caused by non-canonical tab routes.
- Fixed mobile terminal tab remount/routing restore issues.
- Fixed agent metadata title/branch update reliability.
@@ -558,7 +733,9 @@
- Fixed reversed edge-wheel scroll behavior in chat/tool stream views.
## 0.1.16 - 2026-02-22
### Added
- Update the Paseo desktop app and local daemon directly from Settings.
- Microphone and notification permission controls in Settings.
- Thinking/reasoning mode — agents can use extended thinking when the provider supports it.
@@ -566,6 +743,7 @@
- `paseo wait` now shows a snapshot of recent agent activity while you wait.
### Improved
- Smoother streaming with less UI flicker and scroll jumping during long agent runs.
- Faster agent sidebar list rendering.
- Archiving an agent now stops it first instead of archiving a half-running session.
@@ -573,16 +751,20 @@
- More reliable relay connections.
### Fixed
- Fixed Claude background tasks desyncing the chat.
- Fixed duplicate user messages appearing in the timeline.
- Fixed a startup crash caused by an OpenCode SDK update.
- Fixed spurious "needs attention" notifications from background agent activity.
## 0.1.15 - 2026-02-19
### Added
- Added a public changelog page on the website so users can browse release notes.
### Improved
- Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
@@ -590,11 +772,14 @@
- Hid empty filter groups in the left sidebar.
### Fixed
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
- Fixed duplicate `/rewind` user-message behavior.
## 0.1.14 - 2026-02-19
### Added
- Added Claude `/rewind` command support.
- Added slash command access in the draft agent composer.
- Added `@` workspace file autocomplete in chat prompts.
@@ -603,6 +788,7 @@
- Added shared desktop/web overlay scroll handles, including file preview panes.
### Improved
- Improved worktree flow after shipping, including better merged PR detection.
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
@@ -612,6 +798,7 @@
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
- Fixed terminal sizing when switching back to an agent session.
@@ -625,25 +812,32 @@
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
## 0.1.9 - 2026-02-17
### Improved
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
- Reused provider availability checks for structured generation fallback selection.
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
### Fixed
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
## 0.1.8 - 2026-02-17
### Added
- Added a cross-platform confirm dialog flow for daemon restarts.
### Improved
- Simplified local speech bootstrap and daemon startup locking behavior.
- Updated website hero copy to emphasize local execution.
### Fixed
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
@@ -651,73 +845,93 @@
- Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16
### Added
- Improved agent workspace flows with better directory suggestions.
- Added iOS TestFlight and Android app access request forms on the website.
### Improved
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
- Improved website app download and update guidance.
### Fixed
- Prevented an initial desktop combobox `0,0` position flash.
- Fixed CLI version output issues.
- Hardened server runtime loading for local speech dependencies.
## 0.1.6 - 2026-02-16
### Notes
- No major visible product changes in this patch release.
## 0.1.5 - 2026-02-16
### Added
- Added terminal reattach support and better worktree terminal handling.
- Added global keyboard shortcut help in the app.
- Added sidebar host filtering and improved agent workflow controls.
### Improved
- Improved worktree setup visibility by streaming setup progress.
- Improved terminal streaming reliability and lifecycle handling.
- Preserved explorer tab state so context survives navigation better.
## 0.1.4 - 2026-02-14
### Added
- Added voice capability status reporting in the client.
- Added background local speech model downloads with runtime gating.
- Added adaptive dictation finish timing based on server-provided budgets.
- Added relay reconnect behavior with grace periods and branch suggestions.
### Improved
- Improved connection selection and agent hydration reliability.
- Improved timeline loading with cursor-based fetch behavior.
- Improved first-run experience by bootstrapping a default localhost connection.
- Improved inline code rendering by auto-linkifying URLs.
### Fixed
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
- Fixed stale relay client timer behavior.
- Fixed unnecessary git diff header auto-scroll on collapse.
## 0.1.3 - 2026-02-12
### Added
- Added CLI onboarding command.
- Added CLI `--output-schema` support for structured agent output.
- Added CLI agent metadata update support for names and labels.
- Added provider availability detection with normalization of legacy default model IDs.
### Improved
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
- Added better voice interrupt handling with a speech-start grace period.
- Improved CLI defaults to list all non-archived agents by default.
- Improved website UX with clearer install CTA and privacy policy access.
### Fixed
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
## 0.1.2 - 2026-02-11
### Notes
- No major visible product changes in this patch release.
## 0.1.1 - 2026-02-11
### Added
- Initial `0.1.x` release line.

View File

@@ -17,17 +17,17 @@ This is an npm workspace monorepo:
## Documentation
| Doc | What's in it |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| Doc | What's in it |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
@@ -36,6 +36,7 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run lint # Always run after changes
npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
@@ -53,8 +54,11 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- 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 after every change.**
- **Always run typecheck and lint after every change.**
- **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:
- `npm run lint -- packages/app/src/components/message.tsx`
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
@@ -68,23 +72,23 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
### The four gates
| Gate | Type | When to use |
|---|---|---|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
| Gate | Type | When to use |
| -------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
### Decision matrix
| I need to... | Use |
|---|---|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
| I need to... | Use |
| -------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
### Rules
@@ -104,5 +108,4 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
## Debugging
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log

View File

@@ -69,6 +69,7 @@ paseo
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
@@ -116,6 +117,7 @@ Then use them in any agent conversation:
## Development
Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows

View File

@@ -1,5 +0,0 @@
{
"android": {
"package": "com.moboudra.paseo"
}
}

View File

@@ -1,32 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!*.lock"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"linter": {
"enabled": false
}
}

View File

@@ -58,6 +58,7 @@ await rm(staticDir, { recursive: true, force: true });
```
Run with:
```bash
npx tsx packages/server/src/server/your-script.ts
```
@@ -107,6 +108,7 @@ const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "i
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
Always pass `appVersion`:
```typescript
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,

View File

@@ -4,9 +4,9 @@
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
| Variant | App name | Package ID |
|---|---|---|
| `production` | Paseo | `sh.paseo` |
| Variant | App name | Package ID |
| ------------- | ----------- | ---------------- |
| `production` | Paseo | `sh.paseo` |
| `development` | Paseo Debug | `sh.paseo.debug` |
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.

View File

@@ -53,17 +53,17 @@ The heart of Paseo. A Node.js process that:
**Key modules:**
| Module | Responsibility |
|---|---|
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
| Module | Responsibility |
| ------------------------- | ----------------------------------------------------------------------------- |
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
### `packages/app` — Mobile + web client (Expo)
@@ -132,6 +132,7 @@ Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capab
**Binary multiplexing:**
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 0: control messages
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
@@ -153,13 +154,14 @@ initializing → idle → running → idle (or error → closed)
Each provider implements a common `AgentClient` interface:
| Provider | Wraps | Session format |
|---|---|---|
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
| Provider | Wraps | Session format |
| -------- | ------------------- | -------------------------------------------------- |
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
All providers:
- Handle their own authentication (Paseo does not manage API keys)
- Support session resume via persistence handles
- Map tool calls to a normalized `ToolCallDetail` type

View File

@@ -40,7 +40,10 @@ No complex inline types in public function signatures.
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
// Good
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
interface EnqueueJobInput {
userId: string;
priority: "low" | "normal" | "high";
}
function enqueueJob(input: EnqueueJobInput) {}
```
@@ -53,7 +56,11 @@ If a function needs more than one argument, use a single object parameter.
function createToolCall(provider: string, toolName: string, payload: unknown) {}
// Good: object param
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
interface CreateToolCallInput {
provider: string;
toolName: string;
payload: unknown;
}
function createToolCall(input: CreateToolCallInput) {}
```
@@ -78,7 +85,11 @@ Use discriminated unions instead of bags of booleans and optionals.
```typescript
// Bad
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
interface FetchState {
isLoading: boolean;
error?: Error;
data?: Data;
}
// Good
type FetchState =
@@ -136,7 +147,10 @@ Avoid packing branching, lookup, and transformation into single dense expression
// Bad: nested ternaries + inline lookups
const billing = shouldUseLegacy(account)
? getLegacy(account)
: buildBilling(account, rates.find((r) => r.region === account.region));
: buildBilling(
account,
rates.find((r) => r.region === account.region),
);
// Good: named steps, then assemble
const rate = rates.find((r) => r.region === account.region);

View File

@@ -55,6 +55,7 @@ Use `extends` to create a new provider entry that inherits from a built-in provi
```
Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
@@ -95,12 +96,12 @@ Required fields for custom providers:
### Available models
| Model | Tier |
|---|---|
| `glm-5.1` | Advanced (flagship) |
| `glm-5-turbo` | Advanced |
| `glm-4.7` | Standard |
| `glm-4.5-air` | Lightweight |
| Model | Tier |
| ------------- | ------------------- |
| `glm-5.1` | Advanced (flagship) |
| `glm-5-turbo` | Advanced |
| `glm-4.7` | Standard |
| `glm-4.5-air` | Lightweight |
### Notes
@@ -148,10 +149,10 @@ Required fields for custom providers:
### API endpoints
| Mode | Base URL |
|---|---|
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
| Mode | Base URL |
| ------------------------------- | ----------------------------------------------------------- |
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
@@ -159,13 +160,13 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
**Recommended for coding plan:**
| Model | Notes |
|---|---|
| `qwen3.5-plus` | Vision capable, recommended |
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
| Model | Notes |
| ------------------ | --------------------------- |
| `qwen3.5-plus` | Vision capable, recommended |
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
**Additional models (pay-as-you-go):**
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
@@ -221,16 +222,12 @@ You can also combine profiles with model overrides to pin specific models per pr
"claude-fast": {
"extends": "claude",
"label": "Claude (Fast)",
"models": [
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
]
"models": [{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }]
},
"claude-smart": {
"extends": "claude",
"label": "Claude (Smart)",
"models": [
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
]
"models": [{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }]
}
}
}
@@ -338,6 +335,7 @@ Set `extends: "acp"` and provide a `command`:
```
Required fields for ACP providers:
- `extends: "acp"`
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
@@ -424,44 +422,85 @@ Models and modes are discovered dynamically at runtime from the agent process. I
Profile models (defined in config.json) completely replace runtime-discovered models when present.
If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead.
Example: add an experimental model while keeping every model the provider discovers at runtime:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
]
}
}
}
}
```
Example: relabel a discovered model without replacing the full list:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [{ "id": "provider/model-id", "label": "My Preferred Label" }]
}
}
}
}
```
When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place.
---
## Provider override reference
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
|---|---|---|---|
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
| Field | Type | Required | Description |
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
Each entry in the `models` array:
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Model identifier sent to the provider |
| `label` | `string` | Yes | Display name in the UI |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default model selection |
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
| Field | Type | Required | Description |
| ----------------- | ------------------ | -------- | ------------------------------------- |
| `id` | `string` | Yes | Model identifier sent to the provider |
| `label` | `string` | Yes | Display name in the UI |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default model selection |
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
### Thinking option
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Thinking option identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
| Field | Type | Required | Description |
| ------------- | --------- | -------- | ----------------------------------- |
| `id` | `string` | Yes | Thinking option identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Gotcha: `extends: "claude"` with third-party endpoints

View File

@@ -34,88 +34,88 @@ $PASEO_HOME/
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
| Field | Type | Description |
|---|---|---|
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
| Field | Type | Description |
| ------------------ | -------------------------- | ---------------------------- |
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
### Nested: RuntimeInfo
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
| Field | Type | Description |
| ------------------ | -------------------------- | ------------------------------ |
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
### Nested: PersistenceHandle
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
| Field | Type | Description |
| -------------- | ---------------------- | --------------------------------------------------------------------- |
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
### Nested: AgentFeature (discriminated union on `type`)
**Toggle:**
| Field | Type |
|---|---|
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
| Field | Type |
| ------------- | ---------- |
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
**Select:**
| Field | Type |
|---|---|
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
| Field | Type |
| ------------- | --------------------- |
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
@@ -172,22 +172,22 @@ All fields are optional with sensible defaults.
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
### Nested: ScheduleCadence (discriminated union on `type`)
@@ -201,16 +201,16 @@ One file per schedule. ID is 8 hex characters.
### Nested: ScheduleRun
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
| Field | Type | Description |
| -------------- | -------------------------------------- | ----------------------- |
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
---
@@ -229,25 +229,25 @@ Single file containing all rooms and messages.
### ChatRoom
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `name` | `string` | Unique room name (case-insensitive) |
| `purpose` | `string?` | Room description |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
| Field | Type | Description |
| ----------- | ------------------- | ----------------------------------- |
| `id` | `string` (UUID) | |
| `name` | `string` | Unique room name (case-insensitive) |
| `purpose` | `string?` | Room description |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
### ChatMessage
| Field | Type | Description |
|---|---|---|
| `id` | `string` (UUID) | |
| `roomId` | `string` | FK to ChatRoom.id |
| `authorAgentId` | `string` | Agent ID of the author |
| `body` | `string` | Message text (supports `@mentions`) |
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
| `createdAt` | `string` (ISO 8601) | |
| Field | Type | Description |
| ------------------ | ------------------- | ----------------------------------- |
| `id` | `string` (UUID) | |
| `roomId` | `string` | FK to ChatRoom.id |
| `authorAgentId` | `string` | Agent ID of the author |
| `body` | `string` | Message text (supports `@mentions`) |
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
| `createdAt` | `string` (ISO 8601) | |
---
@@ -257,84 +257,84 @@ Single file containing all rooms and messages.
Single file containing an array of all loop records.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char UUID prefix |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | Worker prompt |
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
| `sleepMs` | `number` | Delay between iterations (ms) |
| `maxIterations` | `number?` | Cap on iterations |
| `maxTimeMs` | `number?` | Total time budget (ms) |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `startedAt` | `string` (ISO 8601) | |
| `completedAt` | `string?` (ISO 8601) | |
| `stopRequestedAt` | `string?` (ISO 8601) | |
| `iterations` | `LoopIteration[]` | |
| `logs` | `LoopLogEntry[]` | |
| `nextLogSeq` | `number` | Monotonic log sequence counter |
| `activeIteration` | `number?` | Currently executing iteration index |
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
| Field | Type | Description |
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
| `id` | `string` | 8-char UUID prefix |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | Worker prompt |
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
| `sleepMs` | `number` | Delay between iterations (ms) |
| `maxIterations` | `number?` | Cap on iterations |
| `maxTimeMs` | `number?` | Total time budget (ms) |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `startedAt` | `string` (ISO 8601) | |
| `completedAt` | `string?` (ISO 8601) | |
| `stopRequestedAt` | `string?` (ISO 8601) | |
| `iterations` | `LoopIteration[]` | |
| `logs` | `LoopLogEntry[]` | |
| `nextLogSeq` | `number` | Monotonic log sequence counter |
| `activeIteration` | `number?` | Currently executing iteration index |
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
### Nested: LoopIteration
| Field | Type | Description |
|---|---|---|
| `index` | `number` | 1-based iteration index |
| `workerAgentId` | `string?` | Agent ID of the worker |
| `workerStartedAt` | `string` (ISO 8601) | |
| `workerCompletedAt` | `string?` (ISO 8601) | |
| `verifierAgentId` | `string?` | Agent ID of the verifier |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
| `failureReason` | `string?` | |
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
| Field | Type | Description |
| ------------------- | --------------------------------------------------- | ------------------------ |
| `index` | `number` | 1-based iteration index |
| `workerAgentId` | `string?` | Agent ID of the worker |
| `workerStartedAt` | `string` (ISO 8601) | |
| `workerCompletedAt` | `string?` (ISO 8601) | |
| `verifierAgentId` | `string?` | Agent ID of the verifier |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
| `failureReason` | `string?` | |
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
### Nested: LoopLogEntry
| Field | Type |
|---|---|
| `seq` | `number` (monotonic) |
| `timestamp` | `string` (ISO 8601) |
| `iteration` | `number?` |
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
| `level` | `"info" \| "error"` |
| `text` | `string` |
| Field | Type |
| ----------- | ---------------------------------------------------- |
| `seq` | `number` (monotonic) |
| `timestamp` | `string` (ISO 8601) |
| `iteration` | `number?` |
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
| `level` | `"info" \| "error"` |
| `text` | `string` |
### Nested: LoopVerifyCheckResult
| Field | Type |
|---|---|
| `command` | `string` |
| `exitCode` | `number` |
| `passed` | `boolean` |
| `stdout` | `string` |
| `stderr` | `string` |
| `startedAt` | `string` (ISO 8601) |
| Field | Type |
| ------------- | ------------------- |
| `command` | `string` |
| `exitCode` | `number` |
| `passed` | `boolean` |
| `stdout` | `string` |
| `stderr` | `string` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
### Nested: LoopVerifyPromptResult
| Field | Type |
|---|---|
| `passed` | `boolean` |
| `reason` | `string` |
| `verifierAgentId` | `string?` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
| Field | Type |
| ----------------- | ------------------- |
| `passed` | `boolean` |
| `reason` | `string` |
| `verifierAgentId` | `string?` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
---
@@ -344,15 +344,15 @@ Single file containing an array of all loop records.
Array of project records.
| Field | Type | Description |
|---|---|---|
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
| Field | Type | Description |
| ------------- | -------------------- | ------------------------------ |
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
@@ -362,16 +362,16 @@ Array of project records.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
|---|---|---|
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ----------------------- |
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
@@ -417,12 +417,12 @@ Stores binary attachment blobs keyed by attachment ID.
### AttachmentMetadata
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |
| Field | Type | Description |
| ------------- | --------- | ------------------------------ |
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |

View File

@@ -22,10 +22,16 @@ PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
and `config.json`; actual checkout/worktree directories are not copied.
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
### Default ports
In the main checkout:
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
@@ -58,13 +64,13 @@ Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens
Every `scripts` entry with `"type": "service"` receives these environment variables:
| Variable | Value |
|---|---|
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| Variable | Value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
@@ -130,11 +136,13 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
Find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
Find by content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
```
@@ -144,11 +152,13 @@ rg -l "some title text" $PASEO_HOME/agents/
Get the session ID from the agent JSON (`persistence.sessionId`), then:
**Claude:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```

View File

@@ -35,8 +35,8 @@ cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
@@ -52,58 +52,58 @@ cat node_modules/material-icon-theme/icons/ICON_NAME.svg
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |
| Extension(s) | Icon |
| ------------------------------------------ | ----------- |
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |

View File

@@ -110,6 +110,33 @@ See `image-picker-repro.yaml` for an example.
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
### New Workspace Creation
The Android workspace-creation regression has a dedicated harness:
```bash
bash packages/app/maestro/test-workspace-create-android-crash.sh
```
For a short recording that starts after launch/connection/sidebar setup:
```bash
bash packages/app/maestro/record-workspace-create-android-focus.sh
```
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
- `android-dev-client.yaml`
- `connect-direct-if-welcome.yaml`
- `open-prepared-project-sidebar.yaml`
- `new-workspace-open-from-sidebar.yaml`
- `new-workspace-select-codex-gpt54.yaml`
- `new-workspace-submit-and-assert-created.yaml`
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
### Inputs that Maestro types into
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
@@ -196,7 +223,7 @@ const styles = StyleSheet.create((theme) => ({
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />
<Animated.View style={[styles.sidebar, animatedStyle]} />;
```
```tsx
@@ -217,7 +244,7 @@ const { theme } = useUnistyles();
<Animated.View
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>
/>;
```
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.

View File

@@ -30,6 +30,7 @@ Each project opens as a workspace. For git projects, the default workspace is th
### Inside a workspace
A workspace is a flexible canvas:
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
@@ -39,6 +40,7 @@ A workspace is a flexible canvas:
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app

View File

@@ -58,10 +58,10 @@ type MyProviderClientOptions = {
export class MyProviderACPAgentClient extends ACPAgentClient {
constructor(options: MyProviderClientOptions) {
super({
provider: "my-provider", // Must match the ID used everywhere else
provider: "my-provider", // Must match the ID used everywhere else
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultModes: MY_PROVIDER_MODES,
capabilities: MY_PROVIDER_CAPABILITIES,
});
@@ -70,7 +70,7 @@ export class MyProviderACPAgentClient extends ACPAgentClient {
// Override isAvailable() if the provider needs specific auth/env vars
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false; // Binary not found
return false; // Binary not found
}
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
}
@@ -212,8 +212,8 @@ export const agentConfigs = {
provider: "my-provider",
model: "default-model-id",
modes: {
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
},
},
} as const satisfies Record<string, AgentTestConfig>;
@@ -264,8 +264,15 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:

View File

@@ -79,7 +79,9 @@ const realSender: EmailSender = { send: sendgrid.send };
function createTestEmailSender() {
const sent: Array<{ to: string; body: string }> = [];
return {
send: async (to: string, body: string) => { sent.push({ to, body }); },
send: async (to: string, body: string) => {
sent.push({ to, body });
},
sent,
};
}
@@ -115,6 +117,7 @@ The test output is the source of truth, not your reading of the code.
## Design for testability
If code isn't testable, refactor it. Signs:
- You want to reach for a mock
- You can't inject a dependency
- You need to test private internals

View File

@@ -19,7 +19,7 @@ The important detail: the automatic native path tracks `props.style`. It does no
Avoid this pattern when the style depends on the theme:
```tsx
<ScrollView contentContainerStyle={styles.container} />
<ScrollView contentContainerStyle={styles.container} />;
const styles = StyleSheet.create((theme) => ({
container: {
@@ -39,10 +39,8 @@ Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `co
```tsx
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.contentContainer}>
{children}
</ScrollView>
</View>
<ScrollView contentContainerStyle={styles.contentContainer}>{children}</ScrollView>
</View>;
const styles = StyleSheet.create((theme) => ({
container: {
@@ -66,10 +64,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
const ThemedScrollView = withUnistyles(ScrollView);
<ThemedScrollView
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
/>
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
```
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
@@ -80,11 +75,8 @@ The smallest escape hatch is to use `useUnistyles()` and pass an inline value th
const { theme } = useUnistyles();
<ScrollView
contentContainerStyle={[
styles.contentContainer,
{ backgroundColor: theme.colors.surface0 },
]}
/>
contentContainerStyle={[styles.contentContainer, { backgroundColor: theme.colors.surface0 }]}
/>;
```
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
@@ -98,8 +90,14 @@ The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
```css
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
.unistyles_j2k2iilhfz {
flex: 1 1 0%;
background-color: var(--colors-surface0);
}
.unistyles_j2k2iilhfz > * {
flex: 1 1 0%;
background-color: var(--colors-surface0);
}
```
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
@@ -113,8 +111,10 @@ Symptoms to watch for:
Quick confirmation in DevTools console:
```js
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
[...document.styleSheets]
.flatMap((s) => [...(s.cssRules || [])])
.map((r) => r.cssText)
.filter((t) => t.includes("unistyles") && t.includes("> *"));
```
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
@@ -130,9 +130,7 @@ We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-c
```tsx
const { theme } = useUnistyles();
<Text style={[styles.title, { color: theme.colors.foreground }]}>
{title}
</Text>
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>;
```
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
@@ -170,7 +168,7 @@ Use `useUnistyles()` inside the component instead:
```tsx
const { theme } = useUnistyles();
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
```
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
@@ -233,4 +231,4 @@ The welcome-screen investigation used this approach to prove the white layer was
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)
- [Local research note: welcome theme split](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md)

96
knip.json Normal file
View File

@@ -0,0 +1,96 @@
{
"$schema": "./node_modules/knip/schema.json",
"workspaces": {
".": {
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
},
"packages/server": {
"entry": [
"src/server/index.ts",
"src/server/exports.ts",
"src/utils/tool-call-parsers.ts",
"src/shared/**/*.ts",
"src/client/**/*.ts",
"src/server/agent/agent-sdk-types.ts",
"src/server/agent/provider-manifest.ts",
"scripts/**/*.{ts,mts,mjs,cjs,js}",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.e2e.ts",
"src/**/*.e2e.tsx"
],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,mts,mjs,cjs,js}"]
},
"packages/app": {
"entry": [
"index.ts",
"app.config.js",
"babel.config.js",
"app/**/*.{ts,tsx}",
"src/**/*.test.{ts,tsx}",
"src/**/*.e2e.{ts,tsx}",
"src/**/*.native.{ts,tsx}",
"e2e/**/*.{ts,tsx}",
"playwright.config.{ts,js}",
"vitest.config.{ts,js}",
"test-stubs/**/*.ts"
],
"project": ["**/*.{ts,tsx,js,jsx}"],
"paths": {
"@server/*": ["../server/src/*"]
},
"ignore": ["android/**", "ios/**", ".expo/**", "dist/**", "scripts/reset-project.js"]
},
"packages/cli": {
"entry": ["src/index.ts", "bin/paseo", "src/**/*.test.{ts,tsx}", "tests/**/*.{ts,tsx}"],
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"]
},
"packages/relay": {
"entry": ["src/index.ts", "src/e2ee.ts", "src/cloudflare-adapter.ts", "src/**/*.test.ts"],
"project": ["src/**/*.ts"]
},
"packages/website": {
"entry": ["src/router.tsx", "vite.config.ts", "src/routes/**/*.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/routeTree.gen.ts"]
},
"packages/desktop": {
"entry": ["src/main.ts", "src/preload.ts", "src/**/*.test.ts"],
"project": ["src/**/*.ts"]
},
"packages/highlight": {
"entry": ["src/index.ts", "src/**/*.test.ts"],
"project": ["src/**/*.ts"]
},
"packages/expo-two-way-audio": {
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"]
}
},
"ignoreDependencies": [
"sherpa-onnx-node",
"@playwright/test",
"material-icon-theme",
"eas-cli",
"wait-on",
"concurrently",
"get-port-cli",
"patch-package",
"cross-env",
"expo-module-scripts",
"buffer",
"metro-config"
],
"ignoreBinaries": [
"expo-module",
"xed",
"eas",
"playwright",
"wrangler",
"powershell",
"tsx",
"vitest",
"open"
]
}

11
lefthook.yml Normal file
View File

@@ -0,0 +1,11 @@
pre-commit:
parallel: true
jobs:
- name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
run: npm run format:check:files -- {staged_files}
- name: lint
glob: "*.{js,jsx,ts,tsx}"
run: npm run lint -- {staged_files}
- name: typecheck
run: npm run typecheck

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
npmDepsHash = "sha256-O5AxXbwGJO52gvmVTzJqgy7nb2dxoVAaikBni3Gtu/s=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

1893
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,26 @@
{
"name": "paseo",
"version": "0.1.60-beta.1",
"version": "0.1.63-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
"development",
"mcp",
"openai",
"realtime",
"voice",
"voice-assistant"
],
"homepage": "https://paseo.sh",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
@@ -19,14 +38,20 @@
"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",
"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",
"test": "npm run test --workspaces --if-present",
"format": "biome format --write .",
"format:check": "biome format .",
"format": "oxfmt .",
"format:files": "oxfmt",
"format:check": "oxfmt --check .",
"format:check:files": "oxfmt --check",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"knip": "knip",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -66,44 +91,22 @@
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"lefthook": "^2.1.6",
"oxfmt": "0.46.0",
"oxlint": "1.61.0",
"patch-package": "^8.0.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
"playwright": "^1.56.1",
"typescript": "^5.9.3",
"ws": "^8.20.0"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
"voice",
"voice-assistant",
"development",
"mcp"
],
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1",
"expo": "~54.0.33",
"react": "19.1.0",
"react-native": "0.81.5"
}
}

View File

@@ -37,3 +37,16 @@ jobs:
params:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [submit_ios]
environment: production
steps:
- uses: eas/checkout
- name: Install fastlane
working_directory: ./packages/app
run: bundle install
- name: Submit for review
working_directory: ./packages/app
run: bundle exec fastlane ios submit_review

3
packages/app/Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

View File

@@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
connectArchiveTabDaemonClient,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
@@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
await passivePage.close();
}
});
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-control-${randomUUID().slice(0, 8)}`,
});
await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]);
await closeWorkspaceAgentTab(page, archived.id);
await archiveAgentFromDaemon(client, archived.id);
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
});
});

View File

@@ -3,12 +3,12 @@ import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-r
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
baseURL: async ({}, use) => {
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
}
await use(`http://localhost:${metroPort}`);
await provide(`http://localhost:${metroPort}`);
},
});
@@ -67,7 +67,7 @@ test.beforeEach(async ({ page }) => {
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
({ daemon, preferences, seedNonce: nonce }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -75,13 +75,13 @@ test.beforeEach(async ({ page }) => {
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));

View File

@@ -6,14 +6,15 @@ import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
type WaitForServerOptions = {
interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
};
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -124,16 +125,21 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
}
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
let pendingResolve: (() => void) | null = resolve;
const settle = () => {
if (!pendingResolve) return;
const fn = pendingResolve;
pendingResolve = null;
clearTimeout(timeout);
fn();
};
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
resolve();
settle();
}, 5000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
child.once("exit", settle);
});
}
@@ -185,12 +191,23 @@ let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
type OfferPayload = {
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
if (trimmed === "current") {
return resolvePaseoHomePath("~/.paseo");
}
return resolvePaseoHomePath(trimmed);
}
interface OfferPayload {
v: 2;
serverId: string;
daemonPublicKeyB64: string;
relay: { endpoint: string };
};
}
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
@@ -220,6 +237,11 @@ if (args[0] === "pr" && args[1] === "list") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
console.error("no pull requests found for branch");
process.exit(1);
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
@@ -233,8 +255,10 @@ process.exit(1);
return binDir;
}
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, "");
return input.replace(ANSI_PATTERN, "");
}
function ensureRelayBuildArtifact(repoRoot: string): void {
@@ -311,42 +335,44 @@ async function waitForPairingOfferFromCli(args: {
);
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
}
const port = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const cleanup = async () => {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
};
async function applyPaseoHomeFork(targetHome: string): Promise<void> {
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
if (!forkSourceHome) {
return;
}
const forkResult = await forkPaseoHomeMetadata({
sourceHome: forkSourceHome,
targetHome,
});
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
console.log(
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
`${forkResult.copiedBytes} bytes)`,
);
if (forkResult.skippedMissing.length > 0) {
console.warn(
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
);
}
}
async function resolveDictationConfig(): Promise<DictationConfig> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -369,213 +395,291 @@ export default async function globalSetup() {
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
return { openAiUsable, localModelsDir };
}
interface RelayStreamState {
failureLine: string | null;
readyForSelectedPort: boolean;
}
function attachRelayStreamHandlers(
child: ChildProcess,
relayPort: number,
buffer: ReturnType<typeof createLineBuffer>,
state: RelayStreamState,
): void {
function handleChunk(data: Buffer, streamTag: "stdout" | "stderr") {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[${streamTag}] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
state.failureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
state.readyForSelectedPort = true;
}
if (streamTag === "stdout") {
console.log(`[relay] ${line}`);
} else {
console.error(`[relay] ${line}`);
}
}
}
child.stdout?.on("data", (data: Buffer) => handleChunk(data, "stdout"));
child.stderr?.on("data", (data: Buffer) => handleChunk(data, "stderr"));
}
async function awaitRelayReady(
child: ChildProcess,
relayPort: number,
state: RelayStreamState,
buffer: ReturnType<typeof createLineBuffer>,
): Promise<void> {
await waitForServer(relayPort, {
label: "Relay dev server",
timeoutMs: 30000,
childProcess: child,
getRecentOutput: buffer.dump,
});
const readyDeadline = Date.now() + 5000;
function isRelayReadyCheckPending(): boolean {
if (state.readyForSelectedPort) return false;
if (state.failureLine !== null) return false;
if (child.exitCode !== null) return false;
if (child.signalCode !== null) return false;
if (Date.now() >= readyDeadline) return false;
return true;
}
while (isRelayReadyCheckPending()) await sleep(100);
if (state.failureLine) {
throw new Error(`Relay startup failed: ${state.failureLine}`);
}
if (!state.readyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
buffer.dump,
)}`,
);
}
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${child.exitCode}, signal ${child.signalCode}).${formatRecentOutput(
buffer.dump,
)}`,
);
}
}
async function startRelay(): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePort();
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
attachRelayStreamHandlers(relayProcess, relayPort, buffer, state);
try {
await awaitRelayReady(relayProcess, relayPort, state, buffer);
return relayPort;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
return child;
}
interface DaemonSpawnArgs {
port: number;
relayPort: number;
metroPort: number;
paseoHome: string;
fakeGhBinDir: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = "";
child.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
args.buffer.add(`[stdout] ${trimmed}`);
console.log(`[daemon] ${trimmed}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
args.buffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
}
});
return child;
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
await loadEnvTestFile(repoRoot);
const port = await getAvailablePort();
const metroPort = await getAvailablePort();
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
fakeGhBinDir = await createFakeGhBin();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
await applyPaseoHomeFork(paseoHome);
const cleanup = () => performCleanup(shouldRemovePaseoHome);
const dictation = await resolveDictationConfig();
try {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
relayPort = await getAvailablePort();
relayLineBuffer = createLineBuffer();
let relayStartupFailureLine: string | null = null;
let relayReadyForSelectedPort = false;
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.error(`[relay] ${line}`);
}
});
try {
await waitForServer(relayPort, {
label: "Relay dev server",
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
});
const readyDeadline = Date.now() + 5000;
while (
!relayReadyForSelectedPort &&
relayStartupFailureLine === null &&
relayProcess?.exitCode === null &&
relayProcess?.signalCode === null &&
Date.now() < readyDeadline
) {
await sleep(100);
}
if (relayStartupFailureLine) {
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
}
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump,
)}`,
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump,
)}`,
);
}
relayStarted = true;
break;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
if (!relayStarted) {
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, "..");
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none", // Don't auto-open browser
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
const relayPort = await startRelay();
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
relayPort,
metroPort,
paseoHome,
fakeGhBinDir,
dictation,
buffer: daemonLineBuffer,
});
metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = "";
daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
console.log(`[daemon] ${trimmed}`);
}
});
daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
}
});
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: "Paseo daemon",
@@ -584,7 +688,7 @@ export default async function globalSetup() {
}),
waitForServer(metroPort, {
label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start
timeoutMs: 120000,
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),

View File

@@ -7,22 +7,22 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
export type ScrollMetrics = {
export interface ScrollMetrics {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
};
}
export type SeededAgent = {
export interface SeededAgent {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
};
}
export type DaemonClientInstance = {
export interface DaemonClientInstance {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ export type DaemonClientInstance = {
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -84,17 +84,15 @@ export function createReplyTurn(label: string): {
};
}
type DaemonClientConfig = {
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (
config: DaemonClientConfig,
) => DaemonClientInstance
new (config: DaemonClientConfig) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(

View File

@@ -27,19 +27,19 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
}
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const entry = parsed[0] as { serverId?: string; connections?: unknown };
if (entry?.serverId !== serverId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: any) =>
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
@@ -47,7 +47,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
)
return true;
return !connections.some(
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === endpoint,
);
} catch {
return true;
@@ -68,10 +69,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon, preferences }) => {
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
@@ -80,6 +81,68 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
await page.reload();
}
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
let registry: unknown;
try {
registry = JSON.parse(registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0] as { serverId?: string; connections?: unknown };
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
return { serverId: daemon.serverId, connections: daemon.connections };
}
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
if (
!Array.isArray(connections) ||
!connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
}
}
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
try {
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (prefs?.serverId !== serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
cause: error,
});
}
}
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
@@ -98,66 +161,18 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
let registry: any;
try {
registry = JSON.parse(snapshot.registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
if (serverId !== expectedServerId) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0];
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
if (daemon.serverId !== expectedServerId) {
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
}
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: any) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
);
}
assertDaemonConnections(connections, expectedEndpoint);
if (!snapshot.prefsRaw) {
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
try {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
}
assertPreferencesMatch(snapshot.prefsRaw, serverId);
}
export const gotoAppShell = async (page: Page) => {
@@ -308,7 +323,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
}
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory;
await expect
.poll(
@@ -343,8 +358,8 @@ export const ensureHostSelected = async (page: Page) => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;

View File

@@ -11,13 +11,13 @@ import {
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export type ArchiveTabAgent = {
export interface ArchiveTabAgent {
id: string;
title: string;
cwd: string;
};
}
type ArchiveTabDaemonClient = {
interface ArchiveTabDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ type ArchiveTabDaemonClient = {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
};
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -73,17 +73,15 @@ function buildSeededStoragePayload() {
};
}
type ArchiveTabDaemonClientConfig = {
interface ArchiveTabDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (
config: ArchiveTabDaemonClientConfig,
) => ArchiveTabDaemonClient
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
@@ -150,21 +148,21 @@ export async function primeAdditionalPage(page: Page): Promise<void> {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
},
{ daemon, preferences, seedNonce },
);
@@ -175,11 +173,11 @@ export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
@@ -231,6 +229,24 @@ export async function expectWorkspaceArchiveOutcome(
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
visible: true,
});
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
await closeButton.first().click();
await expectWorkspaceTabHidden(page, agentId);
}
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
await expectWorkspaceTabVisible(page, agentId);
await expect(
page.getByText("This agent is archived").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
@@ -261,6 +277,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function clickSessionRow(page: Page, title: string): Promise<void> {
const row = getSessionRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },

View File

@@ -91,7 +91,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(page: Page): Promise<void> {
export async function waitForLauncherPanel(_page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}

View File

@@ -17,21 +17,21 @@ type NewWorkspaceDaemonClient = Pick<
| "openProject"
>;
type NewWorkspaceDaemonClientConfig = {
interface NewWorkspaceDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export type OpenedProject = {
export interface OpenedProject {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
};
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -49,9 +49,7 @@ function getDaemonWsUrl(): string {
}
async function loadDaemonClientConstructor(): Promise<
new (
config: NewWorkspaceDaemonClientConfig,
) => NewWorkspaceDaemonClient
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(

View File

@@ -1,20 +1,20 @@
import WebSocket from "ws";
import { WebSocket } from "ws";
type WebSocketLike = {
interface WebSocketLike {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
addEventListener?: (event: string, listener: (event: unknown) => void) => void;
removeEventListener?: (event: string, listener: (event: unknown) => void) => void;
onopen?: ((event: unknown) => void) | null;
onclose?: ((event: unknown) => void) | null;
onerror?: ((event: unknown) => void) | null;
onmessage?: ((event: unknown) => void) | null;
}
export type NodeWebSocketFactory = (
url: string,

View File

@@ -0,0 +1,131 @@
import { existsSync } from "node:fs";
import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
export interface PaseoHomeMetadataForkResult {
sourceHome: string;
targetHome: string;
agentFiles: number;
agentBytes: number;
projectFiles: number;
projectBytes: number;
copiedFiles: number;
copiedBytes: number;
skippedMissing: string[];
}
interface CopyStats {
files: number;
bytes: number;
skippedMissing: string[];
}
export function resolvePaseoHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return path.join(homedir(), value.slice(2));
}
return path.resolve(value);
}
async function copyJsonTree(sourceDir: string, targetDir: string): Promise<CopyStats> {
if (!existsSync(sourceDir)) {
return { files: 0, bytes: 0, skippedMissing: [sourceDir] };
}
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
const entries = await readdir(sourceDir, { withFileTypes: true });
await mkdir(targetDir, { recursive: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
const nested = await copyJsonTree(sourcePath, targetPath);
stats.files += nested.files;
stats.bytes += nested.bytes;
stats.skippedMissing.push(...nested.skippedMissing);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}
await mkdir(path.dirname(targetPath), { recursive: true });
await copyFile(sourcePath, targetPath);
const fileStat = await stat(sourcePath);
stats.files += 1;
stats.bytes += fileStat.size;
}
return stats;
}
async function copyProjectRegistryFiles(
sourceHome: string,
targetHome: string,
): Promise<CopyStats> {
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
const sourceProjectsDir = path.join(sourceHome, "projects");
const targetProjectsDir = path.join(targetHome, "projects");
await mkdir(targetProjectsDir, { recursive: true });
for (const fileName of ["projects.json", "workspaces.json"]) {
const sourcePath = path.join(sourceProjectsDir, fileName);
const targetPath = path.join(targetProjectsDir, fileName);
if (!existsSync(sourcePath)) {
stats.skippedMissing.push(sourcePath);
continue;
}
await copyFile(sourcePath, targetPath);
const fileStat = await stat(sourcePath);
stats.files += 1;
stats.bytes += fileStat.size;
}
return stats;
}
export async function forkPaseoHomeMetadata(input: {
sourceHome: string;
targetHome: string;
}): Promise<PaseoHomeMetadataForkResult> {
const sourceHome = resolvePaseoHomePath(input.sourceHome);
const targetHome = resolvePaseoHomePath(input.targetHome);
if (sourceHome === targetHome) {
throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME.");
}
await mkdir(targetHome, { recursive: true });
// Reset only the copied metadata surface. In particular, do not copy or remove
// worktrees here: forked workspace records should continue to point at the
// original checkout/worktree paths from the source home.
await rm(path.join(targetHome, "agents"), { recursive: true, force: true });
await rm(path.join(targetHome, "projects", "projects.json"), { force: true });
await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true });
const agents = await copyJsonTree(
path.join(sourceHome, "agents"),
path.join(targetHome, "agents"),
);
const projects = await copyProjectRegistryFiles(sourceHome, targetHome);
return {
sourceHome,
targetHome,
agentFiles: agents.files,
agentBytes: agents.bytes,
projectFiles: projects.files,
projectBytes: projects.bytes,
copiedFiles: agents.files + projects.files,
copiedBytes: agents.bytes + projects.bytes,
skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing],
};
}

View File

@@ -0,0 +1,234 @@
import { expect, type Page } from "../fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const REGISTRY_KEY = "@paseo:daemon-registry";
const E2E_KEY = "@paseo:e2e";
interface SavedHostInput {
serverId: string;
label: string;
endpoint: string;
}
export function startupScenario(page: Page) {
return new StartupScenario(page);
}
class StartupScenario {
private readonly page: Page;
private savedHosts: SavedHostInput[] = [];
private desktopBridge = false;
private blockedEndpointPorts = new Set<string>();
private viewport: { width: number; height: number } | null = null;
constructor(page: Page) {
this.page = page;
}
withMobileViewport(): this {
this.viewport = { width: 390, height: 844 };
return this;
}
withSavedHost(input: SavedHostInput): this {
this.savedHosts.push(input);
const port = input.endpoint.match(/:(\d+)$/)?.[1];
if (port) {
this.blockedEndpointPorts.add(port);
}
return this;
}
withPendingDesktopDaemon(): this {
this.desktopBridge = true;
return this;
}
withBlockedPort(port: string): this {
this.blockedEndpointPorts.add(port);
return this;
}
async openRoot(): Promise<StartupAssertions> {
await this.prepare();
await this.page.goto("/");
return new StartupAssertions(this.page);
}
async openHostWorkspace(input: {
serverId: string;
workspaceId: string;
}): Promise<StartupAssertions> {
await this.prepare();
await this.page.goto(
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
);
return new StartupAssertions(this.page);
}
private async prepare(): Promise<void> {
if (this.viewport) {
await this.page.setViewportSize(this.viewport);
}
if (this.desktopBridge) {
await installPendingDesktopBridge(this.page);
}
for (const port of this.blockedEndpointPorts) {
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
});
}
if (this.savedHosts.length === 0) {
return;
}
// Let the shared fixture create its seed nonce, then opt out of that seed for
// the next navigation so this scenario owns the stored host registry.
await this.page.goto("/");
const nowIso = new Date().toISOString();
const registry = this.savedHosts.map((host) =>
buildStoredHost({
serverId: host.serverId,
endpoint: host.endpoint,
label: host.label,
nowIso,
}),
);
const firstHost = registry[0];
if (!firstHost) {
throw new Error("Expected at least one startup test host.");
}
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
await this.page.evaluate(
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
const nonce = localStorage.getItem(keys.seedNonce);
if (!nonce) {
throw new Error("Expected e2e seed nonce before overriding startup registry.");
}
localStorage.setItem(keys.e2e, "1");
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
},
{
keys: {
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
e2e: E2E_KEY,
registry: REGISTRY_KEY,
seedNonce: SEED_NONCE_KEY,
},
registry,
createAgentPreferences,
},
);
}
}
class StartupAssertions {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async expectsReconnectWelcome(): Promise<this> {
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
return this;
}
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
return this;
}
async expectsNoLocalServerStartupCopy(): Promise<this> {
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
0,
);
return this;
}
async expectsDesktopDaemonStartup(): Promise<this> {
await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({
timeout: 15_000,
});
return this;
}
async expectsSidebarHidden(): Promise<this> {
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
return this;
}
async expectsNoUndefinedRoute(): Promise<this> {
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
return this;
}
}
async function installPendingDesktopBridge(page: Page): Promise<void> {
await page.addInitScript(() => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "start_desktop_daemon") {
await new Promise(() => {
// Keep the daemon in the startup phase until the test ends.
});
}
if (command === "desktop_daemon_status") {
return {
serverId: "srv_desktop_pending",
status: "starting",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
if (command === "desktop_daemon_logs") {
return { logPath: "", contents: "" };
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
});
}
function buildStoredHost(input: {
serverId: string;
endpoint: string;
label: string;
nowIso: string;
}) {
return buildSeededHost(input);
}
function buildStoredCreateAgentPreferences(serverId: string) {
return buildCreateAgentPreferences(serverId);
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,98 @@
import type { Page } from "@playwright/test";
import { createTempGitRepo } from "./workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./terminal-perf";
interface TempRepo {
path: string;
cleanup: () => Promise<void>;
}
export interface TerminalInstance {
id: string;
name: string;
cwd: string;
}
export class TerminalE2EHarness {
readonly client: TerminalPerfDaemonClient;
readonly tempRepo: TempRepo;
readonly workspaceId: string;
private constructor(input: {
client: TerminalPerfDaemonClient;
tempRepo: TempRepo;
workspaceId: string;
}) {
this.client = input.client;
this.tempRepo = input.tempRepo;
this.workspaceId = input.workspaceId;
}
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
const tempRepo = await createTempGitRepo(input.tempPrefix);
const client = await connectTerminalClient();
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) {
await client.close().catch(() => {});
await tempRepo.cleanup().catch(() => {});
throw new Error(seedResult.error ?? "Failed to seed workspace");
}
return new TerminalE2EHarness({
client,
tempRepo,
workspaceId: seedResult.workspace.id,
});
}
async cleanup(): Promise<void> {
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
}
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
return result.terminal;
}
async killTerminal(terminalId: string): Promise<void> {
await this.client.killTerminal(terminalId).catch(() => {});
}
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
await navigateToTerminal(page, {
workspaceId: this.workspaceId,
terminalId: input.terminalId,
});
}
terminalSurface(page: Page) {
return page.locator('[data-testid="terminal-surface"]');
}
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
await setupDeterministicPrompt(page, sentinel);
}
}
export async function withTerminalInApp<T>(
page: Page,
harness: TerminalE2EHarness,
input: { name: string },
fn: (terminal: TerminalInstance) => Promise<T>,
): Promise<T> {
const terminal = await harness.createTerminal({ name: input.name });
try {
await harness.openTerminal(page, { terminalId: terminal.id });
return await fn(terminal);
} finally {
await harness.killTerminal(terminal.id);
}
}

View File

@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
export interface TerminalPerfDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -19,6 +19,13 @@ export type TerminalPerfDaemonClient = {
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
cwd: string;
title?: string;
modeId?: string;
}): Promise<{ id: string; status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
@@ -30,7 +37,7 @@ export type TerminalPerfDaemonClient = {
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -48,17 +55,15 @@ function getServerId(): string {
return serverId;
}
type TerminalPerfDaemonClientConfig = {
interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (
config: TerminalPerfDaemonClientConfig,
) => TerminalPerfDaemonClient
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
@@ -95,7 +100,19 @@ function buildWorkspaceUrl(workspaceId: string): string {
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
const term = (
window as Window & {
__paseoTerminal?: {
buffer: {
active: {
length: number;
getLine: (i: number) => { translateToString: (trim: boolean) => string } | null;
};
};
onWriteParsed: (cb: () => void) => { dispose: () => void };
};
}
).__paseoTerminal;
if (!term) {
return "";
}
@@ -186,10 +203,10 @@ export async function setupDeterministicPrompt(page: Page, sentinel?: string): P
await page.waitForTimeout(300);
}
export type LatencySample = {
export interface LatencySample {
char: string;
latencyMs: number;
};
}
/**
* Measures keystroke echo round-trip latency.
@@ -200,12 +217,16 @@ export type LatencySample = {
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
const win = window as Window & {
__paseoTerminal?: { onWriteParsed: (cb: () => void) => { dispose: () => void } };
__perfKeystroke?: { promise: Promise<number> | null };
};
if (!win.__paseoTerminal) {
throw new Error("__paseoTerminal not available");
}
const term = win.__paseoTerminal;
const state = ((window as any).__perfKeystroke = {
const state = (win.__perfKeystroke = {
promise: null as Promise<number> | null,
});
@@ -231,7 +252,11 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
return page.evaluate(
() =>
(window as unknown as { __perfKeystroke: { promise: Promise<number> } }).__perfKeystroke
.promise,
);
}
export function computePercentile(samples: number[], p: number): number {

View File

@@ -0,0 +1,713 @@
import type { Page } from "@playwright/test";
export interface TerminalRenderProbeSnapshot {
setCount: number;
unsetCount: number;
writeCount: number;
resetWrites: number;
clearScreenWrites: number;
altEnterWrites: number;
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
}
export interface TerminalRenderProbeEvent {
at: number;
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
preview?: string;
}
export interface TerminalFrame {
at: number;
rowCount: number;
nonEmptyRows: number;
firstNonEmptyRow: number | null;
text: string;
topText: string;
}
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
frameCount: number;
};
export interface TerminalKeystrokeStressReport {
inputTextLength: number;
keydownCount: number;
inputFrameCount: number;
outputFrameCount: number;
textMessageFrameCount: number;
textMessagePayloadBytes: number;
largeTextMessageCount: number;
largestTextMessageBytes: number;
agentStreamTextMessageCount: number;
agentStreamTextMessagePayloadBytes: number;
largeAgentStreamTextMessageCount: number;
largestAgentStreamTextMessageBytes: number;
appEventCount: number;
appEventCounts: Record<string, number>;
runtimeMaxQueueDepth: number;
xtermWriteCount: number;
inputFramePayloadBytes: number;
outputFramePayloadBytes: number;
keydownToInputFrameMs: LatencyStats | null;
inputFrameToOutputFrameMs: LatencyStats | null;
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
appTerminalEmitListenerDurationMs: LatencyStats | null;
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
outputFrameToXtermWriteMs: LatencyStats | null;
xtermWriteDurationMs: LatencyStats | null;
keydownToXtermCommitMs: LatencyStats | null;
firstKeydownAt: number | null;
lastXtermCommitAt: number | null;
}
export interface LatencyStats {
count: number;
minMs: number;
p50Ms: number;
p95Ms: number;
maxMs: number;
avgMs: number;
}
export async function installTerminalRenderProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
interface ProbeTerm {
write?: (data: string | Uint8Array, callback?: () => void) => void;
__paseoRenderProbeWriteWrapped?: boolean;
}
interface ProbeState {
term: ProbeTerm | undefined;
setCount: number;
unsetCount: number;
writeCount: number;
resetWrites: number;
clearScreenWrites: number;
altEnterWrites: number;
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
rafId: number | null;
sampleUntil: number;
reset: () => void;
snapshot: () => TerminalRenderProbeSnapshot;
startSampling: (durationMs: number) => void;
}
const win = window as unknown as Record<string, unknown> & {
__terminalRenderProbe?: ProbeState;
__paseoTerminal?: ProbeTerm;
};
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
const probe: ProbeState = {
term: getExisting(),
setCount: 0,
unsetCount: 0,
writeCount: 0,
resetWrites: 0,
clearScreenWrites: 0,
altEnterWrites: 0,
altExitWrites: 0,
events: [],
frames: [],
rafId: null,
sampleUntil: 0,
reset() {
this.setCount = 0;
this.unsetCount = 0;
this.writeCount = 0;
this.resetWrites = 0;
this.clearScreenWrites = 0;
this.altEnterWrites = 0;
this.altExitWrites = 0;
this.events = [];
this.frames = [];
},
snapshot() {
return {
setCount: this.setCount,
unsetCount: this.unsetCount,
writeCount: this.writeCount,
resetWrites: this.resetWrites,
clearScreenWrites: this.clearScreenWrites,
altEnterWrites: this.altEnterWrites,
altExitWrites: this.altExitWrites,
events: this.events,
frames: this.frames,
};
},
startSampling(durationMs: number) {
this.sampleUntil = performance.now() + durationMs;
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
const sample = () => {
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
(row) => row.textContent ?? "",
);
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
this.frames.push({
at: performance.now(),
rowCount: rows.length,
nonEmptyRows: nonEmptyRows.length,
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
text: rows.join("\n"),
topText: rows.slice(0, 3).join("\n"),
});
if (performance.now() < this.sampleUntil) {
this.rafId = requestAnimationFrame(sample);
} else {
this.rafId = null;
}
};
this.rafId = requestAnimationFrame(sample);
},
};
Object.defineProperty(win, "__terminalRenderProbe", {
configurable: true,
value: probe,
});
Object.defineProperty(win, "__paseoTerminal", {
configurable: true,
get() {
return probe.term;
},
set(next: ProbeTerm | undefined) {
if (next === undefined) {
probe.unsetCount += 1;
probe.events.push({ at: performance.now(), type: "unset" });
probe.term = next;
return;
}
probe.setCount += 1;
probe.events.push({ at: performance.now(), type: "set" });
probe.term = next;
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
probe.writeCount += 1;
const preview = text
.replaceAll("\u001b", "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.slice(0, 160);
if (text.includes("\u001bc")) {
probe.resetWrites += 1;
probe.events.push({ at: performance.now(), type: "reset-write", preview });
}
if (text.includes("\u001b[2J")) {
probe.clearScreenWrites += 1;
probe.events.push({ at: performance.now(), type: "clear-write", preview });
}
if (text.includes("\u001b[?1049h")) {
probe.altEnterWrites += 1;
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
}
if (text.includes("\u001b[?1049l")) {
probe.altExitWrites += 1;
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
}
return originalWrite(data, callback);
};
next.__paseoRenderProbeWriteWrapped = true;
}
},
});
});
}
interface TerminalRenderProbeWindow {
__terminalRenderProbe: {
reset: () => void;
startSampling: (durationMs: number) => void;
snapshot: () => TerminalRenderProbeSnapshot;
};
}
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.reset();
});
}
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
await page.evaluate((ms) => {
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.startSampling(ms);
}, durationMs);
}
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
return page.evaluate(() =>
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.snapshot(),
);
}
export async function terminalVisibleText(page: Page): Promise<string> {
return page.evaluate(() =>
Array.from(document.querySelectorAll(".xterm-rows > div"))
.map((row) => row.textContent ?? "")
.join("\n"),
);
}
export function summarizeTerminalRenderProbe(
probe: TerminalRenderProbeSnapshot,
): TerminalRenderProbeSummary {
return {
setCount: probe.setCount,
unsetCount: probe.unsetCount,
writeCount: probe.writeCount,
resetWrites: probe.resetWrites,
clearScreenWrites: probe.clearScreenWrites,
altEnterWrites: probe.altEnterWrites,
altExitWrites: probe.altExitWrites,
events: probe.events,
frameCount: probe.frames.length,
};
}
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
interface TimedTextEvent {
at: number;
text: string;
bytes: number;
}
interface TimedTextMessageEvent {
at: number;
bytes: number;
kind: string | null;
}
interface XtermWriteEvent {
at: number;
committedAt: number | null;
text: string;
bytes: number;
}
interface AppProbeEvent {
type: string;
at: number;
bytes?: number;
queueDepth?: number;
}
interface StressProbeState {
keydowns: Array<{ at: number; key: string }>;
inputFrames: TimedTextEvent[];
outputFrames: TimedTextEvent[];
textMessageFrames: TimedTextMessageEvent[];
xtermWrites: XtermWriteEvent[];
appEvents: AppProbeEvent[];
reset: () => void;
report: (inputText: string) => TerminalKeystrokeStressReport;
}
const INPUT_OPCODE = 0x02;
const OUTPUT_OPCODE = 0x01;
const decoder = new TextDecoder();
function bytesFrom(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
const bytes = bytesFrom(data);
if (bytes) return Promise.resolve(bytes);
if (typeof Blob !== "undefined" && data instanceof Blob) {
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
}
return Promise.resolve(null);
}
function frameText(bytes: Uint8Array): string {
return decoder.decode(bytes.slice(2));
}
function eventDataText(data: unknown): Promise<string | null> {
if (typeof data === "string") {
return Promise.resolve(data);
}
if (typeof Blob !== "undefined" && data instanceof Blob) {
return data.text();
}
return Promise.resolve(null);
}
function textMessageKind(text: string): string | null {
try {
const parsed = JSON.parse(text) as {
type?: unknown;
message?: {
type?: unknown;
};
};
if (typeof parsed.type !== "string") {
return null;
}
if (typeof parsed.message?.type === "string") {
return `${parsed.type}:${parsed.message.type}`;
}
return parsed.type;
} catch {
return null;
}
}
function summarize(values: number[]): LatencyStats | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const percentile = (p: number) => {
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
};
const total = values.reduce((sum, value) => sum + value, 0);
const round2 = (value: number) => Math.round(value * 100) / 100;
return {
count: values.length,
minMs: round2(sorted[0] ?? 0),
p50Ms: round2(percentile(50)),
p95Ms: round2(percentile(95)),
maxMs: round2(sorted[sorted.length - 1] ?? 0),
avgMs: round2(total / values.length),
};
}
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
return events.find((event) => event.at >= at) ?? null;
}
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
return (
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
null
);
}
function countByType(events: AppProbeEvent[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const event of events) {
counts[event.type] = (counts[event.type] ?? 0) + 1;
}
return counts;
}
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
return events.filter((event) => event.type === type);
}
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
const count = Math.min(from.length, to.length);
const values: number[] = [];
for (let index = 0; index < count; index += 1) {
values.push(to[index]!.at - from[index]!.at);
}
return values;
}
const probe: StressProbeState = {
keydowns: [],
inputFrames: [],
outputFrames: [],
textMessageFrames: [],
xtermWrites: [],
appEvents: [],
reset() {
this.keydowns = [];
this.inputFrames = [];
this.outputFrames = [];
this.textMessageFrames = [];
this.xtermWrites = [];
this.appEvents = [];
},
report(inputText: string) {
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
const keydownToInputFrame = this.keydowns
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
.filter((at): at is number => at !== null)
.map((at, index) => at - this.keydowns[index]!.at);
const inputFrameToOutputFrame = this.inputFrames
.map((input) => {
const output = firstAtOrAfter(this.outputFrames, input.at);
return output ? output.at - input.at : null;
})
.filter((value): value is number => value !== null);
const outputFrameToXtermWrite = this.outputFrames
.map((output) => {
const write = firstAtOrAfter(this.xtermWrites, output.at);
return write ? write.at - output.at : null;
})
.filter((value): value is number => value !== null);
const xtermWriteDurations = this.xtermWrites
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
.filter((value): value is number => value !== null);
const keydownToXtermCommit = this.keydowns
.map((keydown) => {
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
return write?.committedAt ? write.committedAt - keydown.at : null;
})
.filter((value): value is number => value !== null);
return {
inputTextLength: inputText.length,
keydownCount: this.keydowns.length,
inputFrameCount: this.inputFrames.length,
outputFrameCount: this.outputFrames.length,
textMessageFrameCount: this.textMessageFrames.length,
textMessagePayloadBytes: this.textMessageFrames.reduce(
(sum, frame) => sum + frame.bytes,
0,
),
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
.length,
largestTextMessageBytes: Math.max(
0,
...this.textMessageFrames.map((frame) => frame.bytes),
),
agentStreamTextMessageCount: this.textMessageFrames.filter(
(frame) => frame.kind === "session:agent_stream",
).length,
agentStreamTextMessagePayloadBytes: this.textMessageFrames
.filter((frame) => frame.kind === "session:agent_stream")
.reduce((sum, frame) => sum + frame.bytes, 0),
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
).length,
largestAgentStreamTextMessageBytes: Math.max(
0,
...this.textMessageFrames
.filter((frame) => frame.kind === "session:agent_stream")
.map((frame) => frame.bytes),
),
appEventCount: this.appEvents.length,
appEventCounts: countByType(this.appEvents),
runtimeMaxQueueDepth: Math.max(
0,
...this.appEvents
.map((event) => event.queueDepth)
.filter((value): value is number => typeof value === "number"),
),
xtermWriteCount: this.xtermWrites.length,
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
keydownToInputFrameMs: summarize(keydownToInputFrame),
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
appBinaryReceivedToFrameDecodedMs: summarize(
latencyByIndex(binaryReceived, frameDecoded),
),
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
appTerminalEmitListenerDurationMs: summarize(
latencyByIndex(terminalEmit, terminalEmitted),
),
appTerminalEmitToStreamControllerOutputMs: summarize(
latencyByIndex(terminalEmit, streamControllerOutput),
),
appStreamControllerDecodeToOnOutputMs: summarize(
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
),
appStreamControllerToEmulatorWriteMs: summarize(
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
),
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
),
appRuntimeEnqueuedToOperationStartMs: summarize(
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
),
appRuntimeOperationStartToXtermWriteMs: summarize(
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
),
appRuntimeXtermWriteToCommitMs: summarize(
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
),
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
),
appBinaryReceivedToRuntimeOperationStartMs: summarize(
latencyByIndex(binaryReceived, runtimeOperationStart),
),
appBinaryReceivedToXtermCommitMs: summarize(
latencyByIndex(binaryReceived, runtimeXtermCommitted),
),
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
xtermWriteDurationMs: summarize(xtermWriteDurations),
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
firstKeydownAt: this.keydowns[0]?.at ?? null,
lastXtermCommitAt:
this.xtermWrites
.map((write) => write.committedAt)
.findLast((at): at is number => typeof at === "number") ?? null,
};
},
};
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
configurable: true,
value: probe,
});
document.addEventListener(
"keydown",
(event) => {
if (event.key.length === 1) {
probe.keydowns.push({ at: performance.now(), key: event.key });
}
},
true,
);
const NativeWebSocket = window.WebSocket;
class InstrumentedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) {
super(url);
} else {
super(url, protocols);
}
super.addEventListener("message", (event) => {
void eventDataBytes(event.data).then((bytes) => {
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
return;
}
probe.outputFrames.push({
at: performance.now(),
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
return;
});
void eventDataText(event.data).then((text) => {
if (text === null) {
return;
}
probe.textMessageFrames.push({
at: performance.now(),
bytes: new TextEncoder().encode(text).byteLength,
kind: textMessageKind(text),
});
return;
});
});
}
send(data: Parameters<WebSocket["send"]>[0]): void {
const bytes = bytesFrom(data);
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
probe.inputFrames.push({
at: performance.now(),
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
}
super.send(data);
}
}
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
value: NativeWebSocket.CONNECTING,
});
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
let terminal = getExisting();
Object.defineProperty(window, "__paseoTerminal", {
configurable: true,
get() {
return terminal;
},
set(next: {
write?: (data: string | Uint8Array, callback?: () => void) => void;
__paseoKeystrokeProbeWriteWrapped?: boolean;
}) {
terminal = next;
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
const event: XtermWriteEvent = {
at: performance.now(),
committedAt: null,
text,
bytes: text.length,
};
probe.xtermWrites.push(event);
return originalWrite(data, () => {
event.committedAt = performance.now();
callback?.();
});
};
next.__paseoKeystrokeProbeWriteWrapped = true;
}
},
});
});
}
interface TerminalKeystrokeStressProbeWindow {
__terminalKeystrokeStressProbe: {
reset: () => void;
report: (text: string) => TerminalKeystrokeStressReport;
};
}
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(
window as unknown as TerminalKeystrokeStressProbeWindow
).__terminalKeystrokeStressProbe.reset();
});
}
export async function readTerminalKeystrokeStressReport(
page: Page,
inputText: string,
): Promise<TerminalKeystrokeStressReport> {
return page.evaluate(
(text) =>
(
window as unknown as TerminalKeystrokeStressProbeWindow
).__terminalKeystrokeStressProbe.report(text),
inputText,
);
}

View File

@@ -8,7 +8,7 @@ import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = {
interface WorkspaceSetupDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -52,7 +52,7 @@ type WorkspaceSetupDaemonClient = {
error?: string | null;
}>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
};
}
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
@@ -116,7 +116,7 @@ export async function seedProjectForWorkspaceSetup(
}
export function projectNameFromPath(repoPath: string): string {
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
return repoPath.replace(/\/+$/, "").split("/").findLast(Boolean) ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {

View File

@@ -3,11 +3,11 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
interface TempRepo {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
};
}
export const createTempGitRepo = async (
prefix = "paseo-e2e-",

View File

@@ -17,7 +17,6 @@ import {
} from "./helpers/launcher";
import {
connectTerminalClient,
waitForTerminalContent,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";

View File

@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
@@ -28,6 +28,108 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
async function delayBrowserAgentCreatedStatus(page: Page) {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -191,7 +293,7 @@ test.describe("New workspace flow", () => {
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
@@ -262,12 +364,87 @@ test.describe("New workspace flow", () => {
await agentTabs.first().click();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
await expect(composer).toBeVisible({ timeout: 30_000 });
} finally {
await tempRepo.cleanup();
}
});
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
{
timeout: 30_000,
},
);
const activeWorkspaceDeckEntry = page
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
.filter({ visible: true });
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(0);
agentCreatedDelay.release();
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(1, { timeout: 30_000 });
} finally {
agentCreatedDelay.release();
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {

View File

@@ -0,0 +1,172 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function pressSettingsToggleShortcut(page: Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
}
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
if (!raw) {
return null;
}
return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null;
})
.toBe(expected);
}
async function openAgentRouteAndExpectFocused(input: {
page: Page;
serverId: string;
workspaceId: string;
agentId: string;
}) {
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
await input.page.goto(
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
);
await input.page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForTabBar(input.page);
await expect(
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
}
test.describe("Settings toggle tab regression", () => {
test.describe.configure({ timeout: 180_000 });
test("toggling settings after changing a setting returns to the same workspace tab", async ({
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("settings-toggle-tab-");
const agentIds: string[] = [];
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `settings-toggle-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Queue", exact: true }).click();
await expectSendBehavior(page, "queue");
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
await waitForTabBar(page);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await page.reload();
await waitForTabBar(page);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("agent-route-refresh-");
const agentIds: string[] = [];
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `agent-route-refresh-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
agentId: firstAgent.id,
});
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
agentId: secondAgent.id,
});
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
await page.reload();
await waitForTabBar(page);
await expect(
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
}
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,64 @@
import { test } from "./fixtures";
import { startupScenario } from "./helpers/startup-dsl";
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return port;
}
function getE2EServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
test.describe("Startup loading presentation", () => {
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
const startup = await startupScenario(page)
.withMobileViewport()
.withSavedHost({
serverId: "srv_unreachable_mobile",
label: "Dev",
endpoint: "127.0.0.1:45678",
})
.openRoot();
await startup.expectsReconnectWelcome();
await startup.expectsNoSavedHostStatus({ label: "Dev" });
await startup.expectsNoLocalServerStartupCopy();
});
test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({
page,
}) => {
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openRoot();
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
await startup.expectsNoUndefinedRoute();
});
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
page,
}) => {
const serverId = getE2EServerId();
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openHostWorkspace({
serverId,
workspaceId: "workspace-1",
});
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
});
});

View File

@@ -0,0 +1,543 @@
import { Buffer } from "node:buffer";
import type { CDPSession, Page, TestInfo } from "@playwright/test";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
type WireDirection = "sent" | "received";
type WirePhase = "startup" | "workspace_clicks";
interface ParsedWireMessage {
type: string | null;
requestId: string | null;
entryCount: number | null;
hasMore: boolean | null;
providerEntries: ProviderSnapshotWireEntry[] | null;
}
type WireFrameRecord = ParsedWireMessage & {
phase: WirePhase;
direction: WireDirection;
bytes: number;
};
interface ProviderSnapshotWireEntry {
provider: string;
status: string | null;
modelCount: number;
modeCount: number;
bytes: number;
}
interface WebSocketFrameEvent {
requestId: string;
response: {
opcode: number;
payloadData: string;
};
}
interface WireSummary {
totalFrames: number;
totalBytes: number;
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
byPhase: Record<
WirePhase,
{
frames: number;
bytes: number;
byType: Array<{ type: string; frames: number; bytes: number }>;
rpcCounts: Array<{ requestType: string; count: number }>;
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
}
>;
fetchPages: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
entries: number | null;
hasMore: boolean | null;
bytes: number;
}>;
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
providerSnapshots: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
totalModels: number;
totalModes: number;
bytes: number;
providers: ProviderSnapshotWireEntry[];
}>;
providerSnapshotTotals: Array<{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: string[];
}>;
fork: {
sourceHome: string | null;
targetHome: string | null;
copiedFiles: number | null;
copiedBytes: number | null;
};
}
function extractWorkspaceTestIds(elements: Element[]): string[] {
const result: string[] = [];
for (const element of elements.slice(0, 3)) {
const value = element.getAttribute("data-testid");
if (value) {
result.push(value);
}
}
return result;
}
class WireMonitor {
private phase: WirePhase = "startup";
private session: CDPSession | null = null;
readonly records: WireFrameRecord[] = [];
async start(page: Page): Promise<void> {
this.session = await page.context().newCDPSession(page);
await this.session.send("Network.enable");
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
this.record("sent", event);
});
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
this.record("received", event);
});
}
setPhase(phase: WirePhase): void {
this.phase = phase;
}
hasCompletedStartupFetches(): boolean {
return (
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_agents_response" &&
record.hasMore === false,
) &&
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_workspaces_response" &&
record.hasMore === false,
)
);
}
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
return {
totalFrames: this.records.length,
totalBytes: sumBytes(this.records),
byDirection: {
sent: summarizeDirection(this.records, "sent"),
received: summarizeDirection(this.records, "received"),
},
byPhase: {
startup: summarizePhase(this.records, "startup"),
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
},
fetchPages: this.records
.filter(
(record) =>
record.direction === "received" &&
(record.type === "fetch_agents_response" ||
record.type === "fetch_workspaces_response"),
)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
entries: record.entryCount,
hasMore: record.hasMore,
bytes: record.bytes,
})),
clickedWorkspaces,
providerSnapshots: this.records
.filter((record) => record.direction === "received" && record.providerEntries)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
totalModels: sumProviderModels(record.providerEntries ?? []),
totalModes: sumProviderModes(record.providerEntries ?? []),
bytes: record.bytes,
providers: record.providerEntries ?? [],
})),
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
fork: {
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
},
};
}
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
if (event.response.opcode !== 1) {
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData),
type: `opcode:${event.response.opcode}`,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
});
return;
}
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
...parseWireMessage(event.response.payloadData),
});
}
}
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseWireMessage(payloadData: string): ParsedWireMessage {
try {
const parsed = JSON.parse(payloadData) as unknown;
if (!parsed || typeof parsed !== "object") {
return emptyParsedWireMessage();
}
const envelope = parsed as {
type?: unknown;
message?: unknown;
};
const unwrapped =
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? envelope.message
: parsed;
const message = unwrapped as {
type?: unknown;
requestId?: unknown;
payload?: {
requestId?: unknown;
entries?: unknown;
agents?: unknown;
pageInfo?: { hasMore?: unknown };
};
};
const entries = readMessageEntries(message.payload);
const hasMore = readMessageHasMore(message.payload);
const messageType = typeof message.type === "string" ? message.type : null;
const providerEntries = isProviderSnapshotMessage(messageType)
? parseProviderSnapshotEntries(message.payload)
: null;
return {
type: messageType,
requestId: readMessageRequestId(message),
entryCount: entries ? entries.length : null,
hasMore,
providerEntries,
};
} catch {
return emptyParsedWireMessage();
}
}
function readMessageRequestId(message: {
requestId?: unknown;
payload?: { requestId?: unknown };
}): string | null {
if (typeof message.requestId === "string") {
return message.requestId;
}
if (typeof message.payload?.requestId === "string") {
return message.payload.requestId;
}
return null;
}
function isProviderSnapshotMessage(messageType: string | null): boolean {
return (
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
);
}
function readMessageEntries(
payload:
| {
entries?: unknown;
agents?: unknown;
}
| undefined,
): unknown[] | null {
if (Array.isArray(payload?.entries)) {
return payload.entries;
}
if (Array.isArray(payload?.agents)) {
return payload.agents;
}
return null;
}
function readMessageHasMore(
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
): boolean | null {
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
}
function emptyParsedWireMessage(): ParsedWireMessage {
return {
type: null,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
};
}
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
if (!payload || typeof payload !== "object") {
return null;
}
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) {
return null;
}
const providerEntries: ProviderSnapshotWireEntry[] = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object") {
continue;
}
const provider = (entry as { provider?: unknown }).provider;
if (typeof provider !== "string") {
continue;
}
const models = (entry as { models?: unknown }).models;
const modes = (entry as { modes?: unknown }).modes;
const status = (entry as { status?: unknown }).status;
providerEntries.push({
provider,
status: typeof status === "string" ? status : null,
modelCount: Array.isArray(models) ? models.length : 0,
modeCount: Array.isArray(modes) ? modes.length : 0,
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
});
}
return providerEntries.length > 0 ? providerEntries : null;
}
function sumBytes(records: WireFrameRecord[]): number {
return records.reduce((sum, record) => sum + record.bytes, 0);
}
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
const selected = records.filter((record) => record.direction === direction);
return {
frames: selected.length,
bytes: sumBytes(selected),
};
}
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
const selected = records.filter((record) => record.phase === phase);
return {
frames: selected.length,
bytes: sumBytes(selected),
byType: summarizeByType(selected),
rpcCounts: summarizeRpcCounts(selected),
rpcs: summarizeRpcs(selected),
};
}
function summarizeByType(records: WireFrameRecord[]): Array<{
type: string;
frames: number;
bytes: number;
}> {
const byType = new Map<string, { frames: number; bytes: number }>();
for (const record of records) {
const type = record.type ?? "unknown";
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
current.frames += 1;
current.bytes += record.bytes;
byType.set(type, current);
}
return [...byType.entries()]
.map(([type, value]) => Object.assign({ type }, value))
.sort((left, right) => right.bytes - left.bytes);
}
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
const responsesByRequestId = new Map<string, string>();
for (const record of records) {
if (record.direction !== "received" || !record.requestId) {
continue;
}
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
}
return records
.filter((record) => record.direction === "sent" && record.requestId && record.type)
.map((record) => ({
requestType: record.type ?? "unknown",
requestId: record.requestId ?? "",
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
}));
}
function summarizeRpcCounts(
records: WireFrameRecord[],
): Array<{ requestType: string; count: number }> {
const counts = new Map<string, number>();
for (const record of records) {
if (record.direction !== "sent" || !record.type || !record.requestId) {
continue;
}
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
}
return [...counts.entries()]
.map(([requestType, count]) => ({ requestType, count }))
.sort(
(left, right) =>
right.count - left.count || left.requestType.localeCompare(right.requestType),
);
}
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
}
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
}
function summarizeProviderSnapshots(
records: WireFrameRecord[],
): WireSummary["providerSnapshotTotals"] {
const byProvider = new Map<
string,
{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: Set<string>;
}
>();
for (const record of records) {
if (record.direction !== "received" || !record.providerEntries) {
continue;
}
for (const entry of record.providerEntries) {
const key = `${record.phase}:${entry.provider}`;
const current = byProvider.get(key) ?? {
phase: record.phase,
provider: entry.provider,
frames: 0,
bytes: 0,
maxModels: 0,
maxModes: 0,
statuses: new Set<string>(),
};
current.frames += 1;
current.bytes += entry.bytes;
current.maxModels = Math.max(current.maxModels, entry.modelCount);
current.maxModes = Math.max(current.maxModes, entry.modeCount);
if (entry.status) {
current.statuses.add(entry.status);
}
byProvider.set(key, current);
}
}
return [...byProvider.values()]
.map((entry) => ({
phase: entry.phase,
provider: entry.provider,
frames: entry.frames,
bytes: entry.bytes,
maxModels: entry.maxModels,
maxModes: entry.maxModes,
statuses: [...entry.statuses].sort(),
}))
.sort((left, right) => right.bytes - left.bytes);
}
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
await testInfo.attach("startup-wire-metrics.json", {
body: JSON.stringify(summary, null, 2),
contentType: "application/json",
});
}
test.describe("ad hoc startup wire metrics", () => {
test.skip(
process.env.E2E_WIRE_METRICS !== "1",
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
);
test("measures startup hydration and workspace navigation websocket traffic", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const monitor = new WireMonitor();
await monitor.start(page);
await gotoAppShell(page);
await waitForSidebarHydration(page, 120_000);
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
await page.waitForTimeout(1_000);
const workspaceTestIds = await page
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
.evaluateAll(extractWorkspaceTestIds);
monitor.setPhase("workspace_clicks");
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
for (const testId of workspaceTestIds) {
const row = page.getByTestId(testId);
if (!(await row.isVisible().catch(() => false))) {
continue;
}
const beforeFrames = monitor.records.length;
const beforeBytes = sumBytes(monitor.records);
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.waitForTimeout(1_000);
clickedWorkspaces.push({
testId,
frames: monitor.records.length - beforeFrames,
bytes: sumBytes(monitor.records) - beforeBytes,
});
}
const summary = monitor.summarize(clickedWorkspaces);
await attachSummary(testInfo, summary);
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
console.log(JSON.stringify(summary, null, 2));
console.log("PASEO_STARTUP_WIRE_METRICS_END");
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
expect(clickedWorkspaces.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,141 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import {
installTerminalRenderProbe,
readTerminalRenderProbe,
resetTerminalRenderProbe,
startTerminalFrameSampling,
summarizeTerminalRenderProbe,
} from "./helpers/terminal-probes";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
let lastBufferText = "";
let lastProbe = await readTerminalRenderProbe(page);
try {
await expect
.poll(
async () => {
lastBufferText = await getTerminalBufferText(page);
lastProbe = await readTerminalRenderProbe(page);
return (
lastProbe.altEnterWrites > 0 &&
lastProbe.altExitWrites > 0 &&
lastBufferText.includes(afterAlt)
);
},
{
intervals: [50],
message: `wait for alternate-screen exit and ${afterAlt} output`,
timeout,
},
)
.toBe(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Timed out waiting for alternate-screen exit: ${message}\n${JSON.stringify(
{
afterAlt,
probe: summarizeTerminalRenderProbe(lastProbe),
bufferTextTail: lastBufferText.slice(-500),
},
null,
2,
)}`,
{ cause: error },
);
}
return lastProbe;
}
test.describe("Terminal alternate-screen transitions", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
page,
}, testInfo) => {
test.setTimeout(60_000);
await installTerminalRenderProbe(page);
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
const historyReady = `HISTORY_READY_${Date.now()}`;
await terminal.pressSequentially(
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
{ delay: 0 },
);
function hasHistoryReady(text: string): boolean {
return text.includes(historyReady);
}
await waitForTerminalContent(page, hasHistoryReady, 10_000);
await resetTerminalRenderProbe(page);
await page.waitForTimeout(500);
const settledProbe = await readTerminalRenderProbe(page);
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
0,
);
await resetTerminalRenderProbe(page);
const afterAlt = `AFTER_ALT_${Date.now()}`;
await startTerminalFrameSampling(page);
await terminal.pressSequentially(
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
{ delay: 0 },
);
const probe = await waitForAlternateScreenExit(page, afterAlt, 10_000);
const probeSummary = summarizeTerminalRenderProbe(probe);
await testInfo.attach("alternate-screen-probe", {
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
contentType: "application/json",
});
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
expect(
probe.altEnterWrites,
"test command should enter the alternate screen",
).toBeGreaterThan(0);
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
0,
);
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
const finalBufferText = await getTerminalBufferText(page);
expect(finalBufferText).toContain(historyReady);
expect(finalBufferText).toContain(afterAlt);
function isSuspiciousFrame(frame: (typeof probe.frames)[number]): boolean {
return (
frame.text.includes("$") &&
!frame.text.includes(historyReady) &&
!frame.text.includes(afterAlt) &&
frame.nonEmptyRows <= 2 &&
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1
);
}
const suspiciousFrames = probe.frames.filter(isSuspiciousFrame);
expect(
suspiciousFrames,
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
).toEqual([]);
});
});
});

View File

@@ -0,0 +1,350 @@
import { performance } from "node:perf_hooks";
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
import {
installTerminalKeystrokeStressProbe,
readTerminalKeystrokeStressReport,
resetTerminalKeystrokeStressProbe,
type LatencyStats,
} from "./helpers/terminal-probes";
import { waitForTerminalContent } from "./helpers/terminal-perf";
const INPUT_TEXT = buildStressText(600);
const BIG_DIFF_BYTES = 256_000;
const SMALL_AGENT_STREAM_UPDATES = 1000;
const STRESS_TIMEOUT_MS = 15_000;
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
interface DaemonEchoReport {
inputTextLength: number;
inputFrameCount: number;
outputEventCount: number;
echoedBytes: number;
sendToOutputMs: LatencyStats;
firstSendAt: number;
lastOutputAt: number;
}
terminalPerfDescribe("Terminal keystroke stress", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("logs daemon-only and app keystroke echo latency under burst input", async ({
page,
}, testInfo) => {
test.setTimeout(75_000);
const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" });
try {
const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT);
await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport);
console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport));
expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
} finally {
await harness.killTerminal(daemonTerminal.id);
}
await installTerminalKeystrokeStressProbe(page);
const appBaselineReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress",
});
await attachJson(testInfo, "app-keystroke-stress", appBaselineReport);
console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport));
expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
const appSmallChunksReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress-small-agent-chunks",
agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES,
});
await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport);
console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport));
expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual(
SMALL_AGENT_STREAM_UPDATES,
);
const appBigDiffReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress-big-diff",
bigDiffBytes: BIG_DIFF_BYTES,
});
await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport);
console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport));
expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1);
expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual(
BIG_DIFF_BYTES,
);
});
});
async function measureAppBurstEcho(input: {
page: Page;
harness: TerminalE2EHarness;
terminalName: string;
bigDiffBytes?: number;
agentStreamUpdateCount?: number;
}) {
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
try {
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
await input.harness.setupPrompt(input.page);
const agent =
input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined
? null
: await input.harness.client.createAgent({
provider: "mock",
cwd: input.harness.tempRepo.path,
title: "Large WebSocket payload",
modeId: "load-test",
});
const bigDiffBytes = input.bigDiffBytes;
const agentStreamUpdateCount = input.agentStreamUpdateCount;
const startAgentLoad =
agent === null
? null
: () => {
if (bigDiffBytes !== undefined) {
return emitLargeDiffAgentPayload(input.harness, {
agentId: agent.id,
bytes: bigDiffBytes,
});
}
if (agentStreamUpdateCount !== undefined) {
return emitRapidAgentStreamUpdates(input.harness, {
agentId: agent.id,
count: agentStreamUpdateCount,
});
}
return Promise.resolve();
};
const terminal = input.harness.terminalSurface(input.page);
await terminal.press("Control+c");
await input.page.waitForTimeout(200);
await resetTerminalKeystrokeStressProbe(input.page);
const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad();
await terminal.pressSequentially(INPUT_TEXT, { delay: 0 });
await waitForAppStressEcho(input.page, INPUT_TEXT);
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
if (input.bigDiffBytes !== undefined) {
await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes);
}
if (input.agentStreamUpdateCount !== undefined) {
await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount);
}
await activeAgentLoadPromise;
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
} finally {
await input.harness.killTerminal(appTerminal.id);
}
}
async function emitRapidAgentStreamUpdates(
harness: TerminalE2EHarness,
input: { agentId: string; count: number },
): Promise<void> {
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
}
async function emitLargeDiffAgentPayload(
harness: TerminalE2EHarness,
input: { agentId: string; bytes: number },
): Promise<void> {
await harness.client.sendAgentMessage(
input.agentId,
`emit ${input.bytes} byte large diff agent stream update`,
);
}
async function measureDaemonBurstEcho(
harness: TerminalE2EHarness,
terminal: TerminalInstance,
inputText: string,
): Promise<DaemonEchoReport> {
await harness.client.subscribeTerminal(terminal.id);
const outputTimesByByte: number[] = [];
let outputEventCount = 0;
let echoedBytes = 0;
const decoder = new TextDecoder();
const unsubscribe = harness.client.onTerminalStreamEvent((event) => {
if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) {
return;
}
outputEventCount += 1;
const text = decoder.decode(event.data);
const now = performance.now();
const previousEchoedBytes = echoedBytes;
echoedBytes += text.length;
for (let index = previousEchoedBytes; index < echoedBytes; index += 1) {
outputTimesByByte[index] = now;
}
});
try {
await new Promise((resolve) => setTimeout(resolve, 250));
echoedBytes = 0;
outputTimesByByte.length = 0;
const sendTimes: number[] = [];
for (const char of inputText) {
sendTimes.push(performance.now());
harness.client.sendTerminalInput(terminal.id, {
type: "input",
data: char,
});
}
await waitForDaemonEchoBytes({
getEchoedBytes: () => echoedBytes,
expectedBytes: inputText.length,
timeoutMs: STRESS_TIMEOUT_MS,
});
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
return {
inputTextLength: inputText.length,
inputFrameCount: sendTimes.length,
outputEventCount,
echoedBytes,
sendToOutputMs: summarizeLatency(latencies),
firstSendAt: sendTimes[0] ?? 0,
lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0,
};
} finally {
unsubscribe();
}
}
async function waitForDaemonEchoBytes(input: {
getEchoedBytes: () => number;
expectedBytes: number;
timeoutMs: number;
}): Promise<void> {
const deadline = Date.now() + input.timeoutMs;
while (Date.now() < deadline) {
if (input.getEchoedBytes() >= input.expectedBytes) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(
`Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`,
);
}
async function waitForAppStressEcho(page: Page, text: string): Promise<void> {
const tail = text.slice(-80);
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
}
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.outputFramePayloadBytes >= expectedBytes) {
return;
}
await page.waitForTimeout(25);
}
}
async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.largestAgentStreamTextMessageBytes >= expectedBytes) {
return;
}
await page.waitForTimeout(25);
}
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
throw new Error(
`Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`,
);
}
async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.agentStreamTextMessageCount >= expectedCount) {
return;
}
await page.waitForTimeout(25);
}
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
throw new Error(
`Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`,
);
}
async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise<void> {
await testInfo.attach(name, {
body: JSON.stringify(value, null, 2),
contentType: "application/json",
});
}
function buildStressText(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let output = "";
while (output.length < length) {
output += alphabet;
}
return output.slice(0, length);
}
function summarizeLatency(values: number[]): LatencyStats {
const sorted = [...values].sort((a, b) => a - b);
const percentile = (p: number) => {
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
};
const total = values.reduce((sum, value) => sum + value, 0);
return {
count: values.length,
minMs: round2(sorted[0] ?? 0),
p50Ms: round2(percentile(50)),
p95Ms: round2(percentile(95)),
maxMs: round2(sorted[sorted.length - 1] ?? 0),
avgMs: round2(total / values.length),
};
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}

View File

@@ -16,8 +16,10 @@ const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
test.describe("Terminal wire performance", () => {
terminalPerfDescribe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;

View File

@@ -139,6 +139,35 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.evaluate(
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
agentId,
reason: "finished",
},
},
cancelable: true,
}),
);
},
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await switchWorkspaceViaSidebar({
page,
serverId,

View File

@@ -19,6 +19,18 @@ function getServerId(): string {
return serverId;
}
interface WorkspaceScriptStarter {
startWorkspaceScript(
workspaceId: string,
scriptName: string,
): Promise<{
workspaceId: string;
scriptName: string;
terminalId: string | null;
error: string | null;
}>;
}
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
@@ -226,7 +238,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("launches script terminals after setup completes", async ({ page }) => {
test("launches script terminals from the workspace scripts menu", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", {
@@ -263,12 +275,15 @@ test.describe("Workspace setup streaming", () => {
await waitForWorkspaceTabsVisible(page);
// Wait for the script terminal tab to appear in the tabs bar.
// The tab title shows the command, not the script name.
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("workspace-scripts-start-web").click();
await page.keyboard.press("Escape");
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the script terminal tab
await terminalTab.click();
// Verify the terminal surface rendered
@@ -294,7 +309,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("launches workspace scripts after setup completes", async () => {
test("launches workspace scripts through an explicit daemon request", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: {
@@ -303,7 +318,7 @@ test.describe("Workspace setup streaming", () => {
},
scripts: {
editor: {
command: "npm run dev",
command: "node -e \"console.log('editor ready'); setInterval(() => {}, 1000)\"",
},
},
},
@@ -325,13 +340,24 @@ test.describe("Workspace setup streaming", () => {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
await completed;
const scriptClient = client as typeof client & WorkspaceScriptStarter;
const startResult = await scriptClient.startWorkspaceScript(workspaceId, "editor");
expect(startResult).toMatchObject({
workspaceId,
scriptName: "editor",
terminalId: expect.any(String),
error: null,
});
const findEditorTerminal = (terminal: { name: string }) => terminal.name === "editor";
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspaceDir);
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
return terminals.terminals.find(findEditorTerminal) ?? null;
})
.toMatchObject({
id: expect.any(String),

View File

@@ -39,7 +39,8 @@
"ascAppId": "6758887924"
},
"android": {
"releaseStatus": "draft"
"track": "production",
"releaseStatus": "completed"
}
}
}

View File

@@ -0,0 +1 @@
app_identifier("sh.paseo")

View File

@@ -0,0 +1,42 @@
default_platform(:ios)
APP_IDENTIFIER = "sh.paseo"
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_P8"),
)
build_number = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
)
wait_for_build_processing_to_be_complete(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
build_number: build_number.to_s,
)
deliver(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
build_number: build_number.to_s,
submit_for_review: true,
automatic_release: true,
force: true,
skip_binary_upload: true,
skip_screenshots: true,
skip_metadata: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false,
},
)
end
end

1
packages/app/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -0,0 +1,71 @@
# Maestro Flows
This directory contains local mobile UI flows. Keep flows small enough that a
failure screenshot proves the intended behavior, not just that the app launched.
## New Workspace Android Flow
Use these files when debugging or extending workspace creation on Android:
- `test-workspace-create-android-crash.sh` runs the full regression harness.
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
harness.
- `record-workspace-create-android-focus.sh` records only the focused repro
window after setup.
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
sidebar open and a prepared project visible.
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
and performs the actual workspace creation.
The reusable pieces live in `flows/`:
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
the welcome screen is visible.
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
the compact Android sidebar, and waits for the prepared project.
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
new-workspace action and waits for `/new`.
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
the app landed on the created workspace.
Compose new workspace scenarios out of these primitives instead of copying the
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
file into the same temp directory, so nested `runFlow: flows/...` paths keep
working with `${PASEO_MAESTRO_*}` placeholders.
The flow is intentionally strict. It must:
1. Open a prepared project from the daemon.
2. Tap the project row's new-workspace action.
3. Select an actual provider/model before tapping `Create`.
4. Tap `Create`.
5. Assert the app lands on a workspace header and the draft composer.
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
visible.
7. For the shell harness, grep logcat for `failed to insert view` and
`specified child already has a parent`.
Do not weaken this flow to only wait for `message-input-root`. That can pass on
the wrong route. The header assertion and the `New workspace` negative assertion
are what prove the redirect actually completed.
The scripts assume a development build with package id `sh.paseo.debug`, an
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
daemon.
```bash
bash packages/app/maestro/test-workspace-create-android-crash.sh
bash packages/app/maestro/record-workspace-create-android-focus.sh
```
Optional environment:
```bash
PASEO_MAESTRO_APP_ID=sh.paseo.debug
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
```

View File

@@ -0,0 +1,41 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Handle Expo dev launcher/dev menu screens when testing Android development
# builds. Release builds no-op through these optional/conditional steps.
- tapOn:
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
optional: true
- runFlow:
when:
visible: "DEVELOPMENT SERVERS"
commands:
# Expo dev launcher text matching is flaky on Android here; tap the first
# development server row by coordinate as the fallback.
- tapOn:
point: "50%,8%"
- waitForAnimationToEnd
- runFlow:
when:
visible: "Continue"
commands:
- tapOn: "Continue"
- tapOn:
text: "Continue"
optional: true
- waitForAnimationToEnd
- runFlow:
when:
visible: "Performance monitor"
commands:
# Close the Expo dev menu if it opens as a bottom sheet.
- tapOn:
point: "89%,9%"

View File

@@ -0,0 +1,30 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Connect to the local daemon only when the app is on the welcome screen.
- runFlow:
when:
visible:
id: "welcome-screen"
commands:
- tapOn:
id: "welcome-direct-connection"
- extendedWaitUntil:
visible:
id: "add-host-modal"
timeout: 10000
- tapOn:
id: "direct-host-input"
- eraseText
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
- assertVisible:
id: "direct-host-input"
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
- tapOn:
id: "direct-host-submit"

View File

@@ -24,4 +24,3 @@ appId: sh.paseo
commands:
- tapOn:
point: "50%,20%"

View File

@@ -0,0 +1,17 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
# visible. Ends on the New Workspace screen.
- assertVisible:
id: "sidebar-project-list"
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
- extendedWaitUntil:
visible: "New workspace"
timeout: 30000

View File

@@ -0,0 +1,36 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Select a real provider/model on the New Workspace screen. Do not skip this
# in redirect tests: without a selected model, Create only shows validation and
# never exercises workspace creation.
- tapOn:
id: "agent-preferences-button"
- extendedWaitUntil:
visible:
id: "agent-preferences-sheet"
timeout: 10000
- tapOn:
id: "combined-model-selector"
- extendedWaitUntil:
visible: "Select model"
timeout: 30000
- tapOn: "Codex"
- extendedWaitUntil:
visible: "GPT-5.4"
timeout: 30000
- tapOn: "GPT-5.4"
- tapOn: "Close"
- extendedWaitUntil:
visible: "GPT-5.4"
timeout: 10000

View File

@@ -0,0 +1,23 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start on the New Workspace screen after model selection. Taps Create and
# proves the redirect completed into a workspace draft-agent tab.
- tapOn: "Create"
- extendedWaitUntil:
visible:
id: "workspace-header-title"
timeout: 60000
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 30000
- assertNotVisible: "New workspace"
- assertNotVisible: "Select a model"
- assertNotVisible: "There was a problem loading the project."
- assertNotVisible: "The specified child already has a parent"

View File

@@ -0,0 +1,22 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start from the connected home screen and leave the Android sidebar open with
# the prepared project visible.
- extendedWaitUntil:
visible: "What shall we build today?"
timeout: 60000
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- extendedWaitUntil:
visible: ${PASEO_MAESTRO_PROJECT_NAME}
timeout: 30000

View File

@@ -3,4 +3,3 @@ appId: sh.paseo
- launchApp:
clearState: true
- runFlow: flows/dev-client.yaml

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Records only the Android workspace-creation repro window.
#
# The setup Maestro flow gets the app to the open sidebar with a prepared
# project visible. Recording starts after that, then the focused flow taps the
# new-workspace button, selects a provider/model, taps Create, and asserts the
# app lands on the created workspace rather than remaining on /new.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
SETUP_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-ready-sidebar.yaml"
FOCUS_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-create-focused.yaml"
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"
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}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
render_flow() {
local source="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
perl -0pe '
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
' "$source" > "$target"
}
render_flow_tree() {
mkdir -p "$OUT_DIR/flows"
render_flow "$SETUP_TEMPLATE" "$SETUP_FLOW"
render_flow "$FOCUS_TEMPLATE" "$FOCUS_FLOW"
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
done
}
require_command adb
require_command git
require_command maestro
require_command node
require_command perl
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
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")"
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
printf "# Workspace create Android focused recording\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
fi
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
SETUP_FLOW="$OUT_DIR/workspace-create-android-ready-sidebar.rendered.yaml"
FOCUS_FLOW="$OUT_DIR/workspace-create-android-create-focused.rendered.yaml"
render_flow_tree
echo "=== Focused Android Workspace Create Recording ==="
echo "Output dir: $OUT_DIR"
echo "Video: $LOCAL_VIDEO"
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
adb reverse tcp:6767 tcp:6767 >/dev/null
echo ""
echo "Opening project in daemon..."
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const repoRoot = process.env.REPO_ROOT;
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
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 { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
clientId: `maestro-workspace-create-focus-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const payload = await client.openProject(projectPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "openProject returned no workspace");
}
console.log(
JSON.stringify({
workspaceId: payload.workspace.id,
projectDisplayName: payload.workspace.projectDisplayName,
}),
);
} finally {
await client.close().catch(() => undefined);
}
NODE
echo ""
echo "Staging app at open sidebar..."
(cd "$OUT_DIR" && maestro test "$SETUP_FLOW") 2>&1 | tee "$OUT_DIR/setup.log"
echo ""
echo "Recording focused create flow..."
adb shell rm -f "$DEVICE_VIDEO" >/dev/null 2>&1 || true
adb shell screenrecord --time-limit 90 "$DEVICE_VIDEO" &
SCREENRECORD_PID=$!
sleep 1
set +e
(cd "$OUT_DIR" && maestro test "$FOCUS_FLOW") 2>&1 | tee "$OUT_DIR/focus.log"
FOCUS_STATUS=${PIPESTATUS[0]}
set -e
kill -INT "$SCREENRECORD_PID" >/dev/null 2>&1 || true
wait "$SCREENRECORD_PID" >/dev/null 2>&1 || true
adb shell pkill -INT screenrecord >/dev/null 2>&1 || true
adb pull "$DEVICE_VIDEO" "$LOCAL_VIDEO" >/dev/null
ls -lh "$LOCAL_VIDEO"
if [ "$FOCUS_STATUS" -ne 0 ]; then
echo "Focused Maestro flow failed. Artifacts: $OUT_DIR" >&2
exit "$FOCUS_STATUS"
fi
echo "Focused recording complete."
echo "Artifacts: $OUT_DIR"

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# Android Maestro harness for the workspace-creation redirect crash.
#
# Starts from a clean app state, connects the Android app to the local daemon,
# opens a prepared git project, creates a workspace through the UI, and captures
# adb logcat around the redirect window.
#
# This harness is deliberately stronger than "composer is visible": it selects
# a model, taps Create, asserts the workspace header, asserts the New Workspace
# route is gone, and fails if logcat contains the Android Fabric view-parent
# crash signature.
#
# Usage:
# bash packages/app/maestro/test-workspace-create-android-crash.sh
#
# Optional environment:
# PASEO_MAESTRO_APP_ID=sh.paseo.debug
# PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
# PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
# PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
set -euo pipefail
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"
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}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
require_command adb
require_command git
require_command maestro
require_command node
require_command perl
render_flow() {
local source="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
perl -0pe '
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
' "$source" > "$target"
}
render_flow_tree() {
mkdir -p "$OUT_DIR/flows"
render_flow "$FLOW_TEMPLATE" "$FLOW"
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
done
}
if [ ! -f "$SERVER_EXPORTS" ]; then
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
echo "Run: npm run build --workspace=@getpaseo/server" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
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")"
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
printf "# Workspace create Android repro\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
else
PROJECT_PARENT=""
fi
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
echo "=== Workspace Create Android Crash Harness ==="
echo "Output dir: $OUT_DIR"
echo "App id: $PASEO_MAESTRO_APP_ID"
echo "Android direct endpoint: $PASEO_MAESTRO_DIRECT_ENDPOINT"
echo "Daemon websocket: $PASEO_MAESTRO_DAEMON_WS_URL"
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
FLOW="$OUT_DIR/workspace-create-android-crash.rendered.yaml"
render_flow_tree
echo "Rendered flow: $FLOW"
echo ""
echo "Preparing Android port reverse..."
adb reverse tcp:6767 tcp:6767 >/dev/null
echo ""
echo "Opening project in daemon..."
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const repoRoot = process.env.REPO_ROOT;
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
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 { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
clientId: `maestro-workspace-create-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const payload = await client.openProject(projectPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "openProject returned no workspace");
}
console.log(
JSON.stringify({
workspaceId: payload.workspace.id,
projectId: payload.workspace.projectId,
projectDisplayName: payload.workspace.projectDisplayName,
}),
);
} finally {
await client.close().catch(() => undefined);
}
NODE
LOGCAT_PID=""
cleanup() {
if [ -n "$LOGCAT_PID" ]; then
kill "$LOGCAT_PID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
echo ""
echo "Capturing Android logcat..."
adb logcat -c || true
adb logcat -v time > "$OUT_DIR/logcat.txt" &
LOGCAT_PID="$!"
echo "Running Maestro flow..."
set +e
(cd "$OUT_DIR" && maestro test "$FLOW") 2>&1 | tee "$OUT_DIR/maestro.log"
MAESTRO_STATUS=${PIPESTATUS[0]}
set -e
cleanup
LOGCAT_PID=""
if [ "$MAESTRO_STATUS" -ne 0 ]; then
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
echo ""
echo "Maestro failed. Artifacts: $OUT_DIR" >&2
exit "$MAESTRO_STATUS"
fi
if grep -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >/dev/null; then
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
echo ""
echo "Android native view crash signature found in logcat. Artifacts: $OUT_DIR" >&2
grep -n -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >&2 || true
exit 1
fi
echo ""
echo "PASS: workspace creation flow completed without the Android view-parent crash signature."
echo "Artifacts: $OUT_DIR"

View File

@@ -0,0 +1,26 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Android regression/reproduction flow for creating a workspace from an existing project.
# The shell wrapper prepares the daemon/project and captures logcat around this flow.
- launchApp:
clearState: true
- runFlow: flows/android-dev-client.yaml
- runFlow: flows/connect-direct-if-welcome.yaml
- extendedWaitUntil:
visible: "What shall we build today?"
timeout: 60000
- takeScreenshot: 01-connected
- runFlow: flows/open-prepared-project-sidebar.yaml
- runFlow: flows/new-workspace-open-from-sidebar.yaml
- takeScreenshot: 02-new-workspace
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
- takeScreenshot: 03-created-workspace

View File

@@ -0,0 +1,8 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Focused repro window. Assumes `workspace-create-android-ready-sidebar.yaml`
# already left the sidebar open with the prepared project visible.
- runFlow: flows/new-workspace-open-from-sidebar.yaml
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
- runFlow: flows/new-workspace-submit-and-assert-created.yaml

View File

@@ -0,0 +1,11 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Setup-only flow. It stops with the Android sidebar open and the prepared
# project visible, so focused recordings can start at the actual repro window.
- launchApp:
clearState: true
- runFlow: flows/android-dev-client.yaml
- runFlow: flows/connect-direct-if-welcome.yaml
- runFlow: flows/open-prepared-project-sidebar.yaml

View File

@@ -22,7 +22,7 @@ const escapedAppSrcRoot = appSrcRoot
const pathSeparatorPattern = "[\\\\/]";
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
...config.resolver.extraNodeModules,
react: path.join(appNodeModulesRoot, "react"),
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),

View File

@@ -1,8 +1,8 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.60-beta.1",
"version": "0.1.63-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
"start": "APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
@@ -17,8 +17,9 @@
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
@@ -29,18 +30,14 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@getpaseo/expo-two-way-audio": "0.1.63-beta.1",
"@getpaseo/highlight": "0.1.63-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
@@ -53,11 +50,10 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
"expo-audio": "~1.0.13",
"expo-av": "^16.0.7",
"expo-build-properties": "^1.0.9",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.7",
@@ -65,7 +61,6 @@
"expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.15",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-picker": "^17.0.8",
@@ -75,34 +70,30 @@
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.33.8",
"react-native-permissions": "^5.4.2",
"react-native-popover-view": "^6.1.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-securerandom": "^1.0.1",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
"zustand": "^5.0.9"
@@ -110,11 +101,16 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/react": "^16.3.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.4",
"@xterm/headless": "^6.0.0",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"jsdom": "^20.0.3",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",

View File

@@ -1,29 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "Desktop Firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});

View File

@@ -1,29 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "Desktop Safari",
use: { ...devices["Desktop Safari"] },
},
],
});

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="%LANG_ISO_CODE%">
<head>
<meta charset="utf-8" />
@@ -21,8 +21,9 @@
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
html, body {
background-color: #181B1A;
html,
body {
background-color: #181b1a;
}
}
/* These styles make the root element full-height */
@@ -38,17 +39,17 @@
input,
textarea,
select,
[role='button'],
[role='link'],
[role='textbox'],
[role='combobox'],
[role='tab'],
[role='switch'],
[role='checkbox'],
[role='slider'],
[role='menuitem'],
[role="button"],
[role="link"],
[role="textbox"],
[role="combobox"],
[role="tab"],
[role="switch"],
[role="checkbox"],
[role="slider"],
[role="menuitem"],
[tabindex],
[contenteditable='true'] {
[contenteditable="true"] {
-webkit-app-region: no-drag !important;
}
@@ -59,7 +60,7 @@
outline: none;
}
*:focus-visible {
outline: 2px solid #20744A;
outline: 2px solid #20744a;
outline-offset: 2px;
}
</style>

View File

@@ -1,5 +1,8 @@
import "@/styles/unistyles";
import { polyfillCrypto } from "@/polyfills/crypto";
import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import {
Stack,
useGlobalSearchParams,
@@ -7,113 +10,98 @@ import {
usePathname,
useRouter,
} from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { QueryClientProvider } from "@tanstack/react-query";
import {
getHostRuntimeStore,
useHosts,
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useState,
useEffect,
type ReactNode,
useMemo,
useRef,
useState,
} from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { LeftSidebar } from "@/components/left-sidebar";
import { View } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { UpdateBanner } from "@/desktop/updates/update-banner";
import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
import {
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { resolveActiveHost } from "@/utils/active-host";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { queryClient } from "@/query/query-client";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { SessionProvider } from "@/contexts/session-context";
import {
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import { getDesktopHost } from "@/desktop/host";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useOpenProject } from "@/hooks/use-open-project";
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
getHostRuntimeStore,
useHostMutations,
useHostRuntimeClient,
useHosts,
} from "@/runtime/host-runtime";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
buildHostRootRoute,
mapPathnameToServer,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
polyfillCrypto();
export type HostRuntimeBootstrapState = {
export interface HostRuntimeBootstrapState {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const firstValue = value[0];
if (typeof firstValue !== "string") {
return undefined;
}
const trimmed = firstValue.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
return undefined;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
@@ -124,7 +112,36 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
function PushNotificationRouter() {
const router = useRouter();
const pathname = usePathname();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const agentId = target.agentId;
if (serverId && agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId);
const workspaceId =
target.workspaceId ??
resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces.values(),
workspaceDirectory: agent?.cwd,
});
if (workspaceId) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
pin: true,
});
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
return;
}
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
@@ -145,7 +162,7 @@ function PushNotificationRouter() {
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
router.push(buildNotificationRoute(data) as any);
openNotification(data);
},
);
@@ -158,6 +175,7 @@ function PushNotificationRouter() {
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
@@ -165,7 +183,7 @@ function PushNotificationRouter() {
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
openNotification(customEvent.detail?.data);
};
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
@@ -198,7 +216,7 @@ function PushNotificationRouter() {
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
router.push(buildNotificationRoute(data) as any);
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
@@ -207,12 +225,13 @@ function PushNotificationRouter() {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
subscription.remove();
};
}, [router]);
}, [openNotification]);
return null;
}
@@ -319,7 +338,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
@@ -401,8 +422,6 @@ function AppContainer({
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
@@ -439,35 +458,6 @@ function AppContainer({
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = isWeb ? window.innerWidth : undefined;
const windowH = isWeb ? window.innerHeight : undefined;
const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = isWeb ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
JSON.stringify({
breakpoint: bp,
isCompactLayout,
isElectron,
chromeEnabled,
isFocusModeEnabled,
agentListOpen,
sidebarWidth,
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
unistylesScreen: { w: screenW, h: screenH },
window: { w: windowW, h: windowH },
devicePixelRatio: dpr,
userAgent: ua,
}),
);
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout,
@@ -494,7 +484,8 @@ function AppContainer({
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<UpdateCalloutSource />
<DaemonVersionMismatchCalloutSource />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
@@ -673,9 +664,10 @@ function OfferLinkListener({
void upsertDaemonFromOfferUrl(url)
.then((profile) => {
if (cancelled) return;
const serverId = (profile as any)?.serverId;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId));
return;
})
.catch((error) => {
if (cancelled) return;
@@ -747,6 +739,7 @@ function OpenProjectListener() {
if (!disposed && pending) {
maybeOpenProject(pending);
}
return;
})
.catch(() => undefined);
@@ -764,6 +757,7 @@ function OpenProjectListener() {
return;
}
unlisten = dispose;
return;
})
.catch(() => undefined);
@@ -783,8 +777,10 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
const storeReady = useStoreReady();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
const shouldShowAppChrome =
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
useEffect(() => {
if (!activeServerId || hosts.length === 0) {
@@ -827,19 +823,24 @@ function FaviconStatusSync() {
return null;
}
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
const stackScreenOptions = useMemo(
() => ({
headerShown: false,
animation: "none" as const,
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}),
[theme.colors.surface0],
);
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}}
>
<Stack screenOptions={stackScreenOptions}>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
@@ -854,12 +855,11 @@ function RootStack() {
outside this route-level native-stack API.
*/}
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
<Stack.Screen name="settings/hosts/[serverId]" />
</Stack>
);
@@ -887,35 +887,59 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
function AppShell() {
return (
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
);
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<SidebarCalloutProvider>
<ToastProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</ToastProvider>
</SidebarCalloutProvider>
</HostRuntimeBootstrapProvider>
);
}
function RootProviders({ children }: { children: ReactNode }) {
return (
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>{children}</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
);
}
export default function RootLayout() {
const { theme } = useUnistyles();
const gestureRootStyle = useMemo(
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<GestureHandlerRootView style={gestureRootStyle}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</GestureHandlerRootView>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
@@ -44,7 +44,7 @@ function HostAgentReadyRouteContent() {
}
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as any);
router.replace("/" as Href);
return;
}
@@ -55,7 +55,7 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as any,
}) as Href,
);
}
}, [agentId, resolvedWorkspaceId, router, serverId]);
@@ -107,11 +107,12 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId,
target: { kind: "agent", agentId },
}) as any,
}) as Href,
);
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {

View File

@@ -15,6 +15,7 @@ import {
} from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
@@ -64,14 +65,16 @@ function stripOpenSearchParamFromBrowserUrl() {
}
function clearConsumedOpenIntent(input: {
navigation: { setParams: (...args: any[]) => void };
router: { replace: (...args: any[]) => void };
navigation: { setParams: (params: { open?: string | undefined }) => void };
router: ReturnType<typeof useRouter>;
serverId: string;
workspaceId: string;
}) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
input.navigation.setParams({ open: undefined });
stripOpenSearchParamFromBrowserUrl();
if (isWeb) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
stripOpenSearchParamFromBrowserUrl();
}
}
export default function HostWorkspaceLayout() {
@@ -86,6 +89,7 @@ function HostWorkspaceLayoutContent() {
const navigation = useNavigation();
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
@@ -126,11 +130,16 @@ function HostWorkspaceLayoutContent() {
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation,
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
router,
serverId,
workspaceId,
@@ -154,16 +163,26 @@ function HostWorkspaceLayoutContent() {
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation,
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
}, [
hasHydratedWorkspaceLayoutStore,
navigation,
openValue,
rootNavigationState?.key,
router,
serverId,
workspaceId,
]);
if (openValue && !intentConsumed) {
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
return null;
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
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";
@@ -147,7 +147,7 @@ export default function PairScanScreen() {
try {
router.back();
} catch {
router.replace("/" as any);
router.replace("/" as Href);
}
}, [router]);
@@ -198,15 +198,29 @@ export default function PairScanScreen() {
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
[insets.bottom, theme.spacing],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={() => router.back()} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<BackHeader title="Scan QR" onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
<Text style={styles.permissionBody}>
QR scanning isn't supported in the web build. Use "Paste link" instead.
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>Back to Settings</Text>
@@ -223,14 +237,14 @@ export default function PairScanScreen() {
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={closeToSource} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Camera permission</Text>
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -239,21 +253,17 @@ export default function PairScanScreen() {
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
onBarcodeScanned={handleScan}
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.scanFrame}>
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
<View style={CORNER_TL_STYLE} />
<View style={CORNER_TR_STYLE} />
<View style={CORNER_BL_STYLE} />
<View style={CORNER_BR_STYLE} />
</View>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing
</Text>
) : null}
{isPairing ? <Text style={helperTextStyle}>Pairing</Text> : null}
</View>
</View>
)}
@@ -261,3 +271,9 @@ export default function PairScanScreen() {
</View>
);
}
const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] };
const CORNER_TL_STYLE = [styles.corner, styles.cornerTL];
const CORNER_TR_STYLE = [styles.corner, styles.cornerTR];
const CORNER_BL_STYLE = [styles.corner, styles.cornerBL];
const CORNER_BR_STYLE = [styles.corner, styles.cornerBR];

View File

@@ -1,4 +1,5 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
@@ -6,6 +7,7 @@ export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
return <SettingsScreen view={{ kind: "section", section }} />;
return <SettingsScreen view={view} />;
}

View File

@@ -1,14 +1,16 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsHostRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={{ kind: "host", serverId }} />
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}

View File

@@ -3,6 +3,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import SettingsScreen from "@/screens/settings-screen";
import { buildSettingsSectionRoute } from "@/utils/host-routes";
const ROOT_VIEW = { kind: "root" as const };
export default function SettingsIndexRoute() {
const isCompactLayout = useIsCompactFormFactor();
@@ -10,5 +12,5 @@ export default function SettingsIndexRoute() {
return <Redirect href={buildSettingsSectionRoute("general")} />;
}
return <SettingsScreen view={{ kind: "root" }} />;
return <SettingsScreen view={ROOT_VIEW} />;
}

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } 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 () => []),
}));
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,
}));
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();
});
it("writes raw base64 sources directly to the managed file path", async () => {
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
const attachment = await store.save({
id: "preview_8_test",
mimeType: "image/png",
fileName: "result.png",
source: { kind: "base64", base64: "AAECAw==" },
});
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
"AAECAw==",
{ encoding: "base64" },
);
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
storageType: "native-file",
storageKey: "/cache/preview-assets/preview_8_test.png",
fileName: "result.png",
byteSize: 4,
});
});
});

View File

@@ -72,6 +72,13 @@ async function writeFromSource(input: {
return;
}
if (input.source.kind === "base64") {
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
encoding: FileSystem.EncodingType.Base64,
});
return;
}
const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
@@ -107,12 +114,14 @@ export function createLocalFileAttachmentStore(params: {
await ensureDirectory(baseDirectory);
const id = input.id ?? generateAttachmentId();
const mimeTypeFromSource =
input.source.kind === "data_url"
? parseDataUrl(input.source.dataUrl).mimeType
: input.source.kind === "blob"
? input.source.blob.type
: undefined;
let mimeTypeFromSource: string | undefined;
if (input.source.kind === "data_url") {
mimeTypeFromSource = parseDataUrl(input.source.dataUrl).mimeType;
} else if (input.source.kind === "blob") {
mimeTypeFromSource = input.source.blob.type;
} else {
mimeTypeFromSource = undefined;
}
const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource);
const fileName = input.fileName ?? null;
const extension = extensionForAttachment({ fileName, mimeType });

View File

@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
id: input.id ?? "att_1",
mimeType: input.mimeType ?? "image/png",
storageType: input.storageType ?? "web-indexeddb",
storageKey: input.storageKey ?? "att_1",
fileName: input.fileName,
byteSize: input.byteSize,
createdAt: input.createdAt ?? 1700000000000,
};
}
function createRecordingStore(): AttachmentStore & {
savedSources: SaveAttachmentInput[];
releasedUrls: string[];
} {
const savedSources: SaveAttachmentInput[] = [];
const releasedUrls: string[] = [];
return {
storageType: "web-indexeddb",
savedSources,
releasedUrls,
async save(input) {
savedSources.push(input);
return createAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
byteSize: 4,
});
},
async encodeBase64({ attachment }) {
return `${attachment.id}:base64`;
},
async resolvePreviewUrl({ attachment }) {
return `blob:${attachment.id}`;
},
async releasePreviewUrl({ url }) {
releasedUrls.push(url);
},
async delete() {},
async garbageCollect() {},
};
}
describe("attachment service", () => {
afterEach(() => {
__setAttachmentStoreForTests(null);
});
it("persists raw base64 without requiring a data URL wrapper", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = await persistAttachmentFromBase64({
id: "att_base64",
base64: "AAECAw==",
mimeType: "image/png",
fileName: "image.png",
});
expect(attachment).toEqual({
id: "att_base64",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_1",
fileName: "image.png",
byteSize: 4,
createdAt: 1700000000000,
});
expect(store.savedSources).toEqual([
{
id: "att_base64",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "base64", base64: "AAECAw==" },
},
]);
});
it("keeps provider send output byte-compatible", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
{ data: "att_send:base64", mimeType: "image/jpeg" },
]);
});
});

View File

@@ -31,6 +31,21 @@ export async function persistAttachmentFromDataUrl(input: {
});
}
export async function persistAttachmentFromBase64(input: {
base64: string;
mimeType?: string;
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "base64", base64: input.base64 },
});
}
export async function persistAttachmentFromFileUri(input: {
uri: string;
mimeType?: string;

View File

@@ -7,9 +7,8 @@ let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (isWeb) {
if (isElectronRuntime()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);
const { createDesktopAttachmentStore } =
await import("../desktop/attachments/desktop-attachment-store");
return createDesktopAttachmentStore();
}

View File

@@ -23,6 +23,7 @@ export type ComposerAttachment =
| { kind: "github_pr"; item: GitHubSearchItem };
export type AttachmentDataSource =
| { kind: "base64"; base64: string }
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string };

View File

@@ -7,29 +7,37 @@ export function useAttachmentPreviewUrl(
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
const attachmentRef = useRef(attachment);
attachmentRef.current = attachment;
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
useEffect(() => {
let disposed = false;
let currentUrl: string | null = null;
const current = attachmentRef.current;
activeAttachmentRef.current = attachment ?? null;
if (!attachment) {
activeAttachmentRef.current = current ?? null;
if (!current) {
setUrl(null);
return;
}
void (async () => {
try {
const resolved = await resolveAttachmentPreviewUrl(attachment);
const resolved = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment, url: resolved });
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
return;
}
currentUrl = resolved;
setUrl(resolved);
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: attachment.id,
attachmentId: current.id,
error,
});
if (!disposed) {
@@ -49,7 +57,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
}, [id, storageType, storageKey, mimeType]);
return url;
}

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