Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e1c786e70b feat(app): store assistant image previews as assets 2026-04-22 22:23:10 +07:00
756 changed files with 55141 additions and 64467 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
--generate-notes
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
+20 -36
View File
@@ -15,30 +15,14 @@ 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 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
run: npx biome format .
typecheck:
runs-on: ubuntu-latest
@@ -47,8 +31,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
@@ -74,8 +58,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
@@ -104,8 +88,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
@@ -142,8 +126,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
@@ -164,8 +148,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 +191,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
@@ -226,8 +210,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
@@ -241,6 +225,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'
+8 -8
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
+5 -4
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,3 +30,4 @@ jobs:
run: cd packages/relay && npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+8 -9
View File
@@ -4,14 +4,13 @@ on:
push:
branches: [main]
paths:
- "CHANGELOG.md"
- "packages/website/**"
- "package.json"
- "package-lock.json"
- "patches/**"
- ".github/workflows/deploy-website.yml"
- 'packages/website/**'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published]
types: [published, edited]
workflow_dispatch:
jobs:
@@ -24,8 +23,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
+23 -23
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" \
--notes "" \
--generate-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
+6 -6
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:
+18 -18
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
+1 -3
View File
@@ -54,9 +54,7 @@ jobs:
fi
create_if_missing="false"
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
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
fi
+10 -10
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
View File
@@ -1,15 +0,0 @@
{
"$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
View File
@@ -1,89 +0,0 @@
{
"$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"
}
}
]
}
-171
View File
@@ -1,50 +1,8 @@
# Changelog
## 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
- 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.
@@ -59,7 +17,6 @@
- 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.
@@ -74,7 +31,6 @@
- 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.
@@ -96,21 +52,18 @@
## 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))
@@ -120,7 +73,6 @@
- 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))
@@ -134,14 +86,12 @@
## 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.
@@ -149,14 +99,12 @@
- 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.
@@ -166,16 +114,13 @@
## 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.
@@ -183,7 +128,6 @@
## 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.
@@ -192,7 +136,6 @@
- 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.
@@ -200,7 +143,6 @@
- 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.
@@ -208,13 +150,11 @@
## 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.
@@ -227,13 +167,11 @@
## 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.
@@ -245,7 +183,6 @@
## 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.
@@ -254,7 +191,6 @@
- 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.
@@ -262,7 +198,6 @@
- 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.
@@ -275,7 +210,6 @@
## 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.
@@ -283,20 +217,17 @@
## 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.
@@ -304,7 +235,6 @@
## 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.
@@ -313,7 +243,6 @@
## 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.
@@ -322,7 +251,6 @@
## 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 .`.
@@ -335,13 +263,11 @@
- 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.
@@ -358,7 +284,6 @@
## 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.
@@ -368,19 +293,16 @@
## 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.
@@ -388,13 +310,11 @@
## 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 (`\\...`).
@@ -406,22 +326,18 @@
- 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.
@@ -432,73 +348,61 @@
## 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.
@@ -507,7 +411,6 @@
- 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.
@@ -524,7 +427,6 @@
## 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.
@@ -533,7 +435,6 @@
## 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.
@@ -543,7 +444,6 @@
- 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.
@@ -558,7 +458,6 @@
- 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.
@@ -567,19 +466,16 @@
## 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.
@@ -587,36 +483,30 @@
## 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.
@@ -624,18 +514,15 @@
## 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.
@@ -645,79 +532,61 @@
## 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.
@@ -725,7 +594,6 @@
- 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.
@@ -733,9 +601,7 @@
- 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.
@@ -743,7 +609,6 @@
- `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.
@@ -751,20 +616,16 @@
- 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.
@@ -772,14 +633,11 @@
- 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.
@@ -788,7 +646,6 @@
- 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.
@@ -798,7 +655,6 @@
- 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.
@@ -812,32 +668,25 @@
- 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.
@@ -845,93 +694,73 @@
- 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.
+26 -29
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,7 +36,6 @@ 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
```
@@ -54,11 +53,8 @@ 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 and lint after every change.**
- **Always run typecheck 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.
@@ -72,23 +68,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
@@ -108,4 +104,5 @@ 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
-2
View File
@@ -69,7 +69,6 @@ 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)
@@ -117,7 +116,6 @@ 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
+5
View File
@@ -0,0 +1,5 @@
{
"android": {
"package": "com.moboudra.paseo"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"$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
}
}
-2
View File
@@ -58,7 +58,6 @@ await rm(staticDir, { recursive: true, force: true });
```
Run with:
```bash
npx tsx packages/server/src/server/your-script.ts
```
@@ -108,7 +107,6 @@ 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`,
+3 -3
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`.
+16 -18
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,7 +132,6 @@ 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
@@ -154,14 +153,13 @@ 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
+4 -18
View File
@@ -40,10 +40,7 @@ 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) {}
```
@@ -56,11 +53,7 @@ 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) {}
```
@@ -85,11 +78,7 @@ 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 =
@@ -147,10 +136,7 @@ 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);
+47 -86
View File
@@ -55,7 +55,6 @@ 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
@@ -96,12 +95,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
@@ -149,10 +148,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`.
@@ -160,13 +159,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`
@@ -222,12 +221,16 @@ 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 }
]
}
}
}
@@ -335,7 +338,6 @@ 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)
@@ -422,85 +424,44 @@ 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) |
| `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 |
| 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 |
### 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
+200 -200
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 |
+6 -11
View File
@@ -31,7 +31,6 @@ PASEO_HOME=~/.paseo-blue npm run dev
### Default ports
In the main checkout:
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
@@ -64,13 +63,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.
@@ -136,13 +135,11 @@ $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/
```
@@ -152,13 +149,11 @@ 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
```
+56 -56
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`:
- 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 |
+2 -2
View File
@@ -223,7 +223,7 @@ const styles = StyleSheet.create((theme) => ({
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />;
<Animated.View style={[styles.sidebar, animatedStyle]} />
```
```tsx
@@ -244,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`.
-2
View File
@@ -30,7 +30,6 @@ 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
@@ -40,7 +39,6 @@ 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
+7 -14
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,15 +264,8 @@ 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:
+1 -4
View File
@@ -79,9 +79,7 @@ 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,
};
}
@@ -117,7 +115,6 @@ 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
+23 -21
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,8 +39,10 @@ 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: {
@@ -64,7 +66,10 @@ 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.
@@ -75,8 +80,11 @@ 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.
@@ -90,14 +98,8 @@ 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.
@@ -111,10 +113,8 @@ 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,7 +130,9 @@ 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.
@@ -168,7 +170,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.
@@ -231,4 +233,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
View File
@@ -1,96 +0,0 @@
{
"$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
View File
@@ -1,11 +0,0 @@
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
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-0Izp63gVeBO9Efo4Uf8/EB3TkQ9FXJ4Td2Fe5UHm9eE=";
npmDepsHash = "sha256-p7luf7/eeQR5seXerJd42x7gY73qqOecStNZN/3wIlk=";
# 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).
+654 -1133
View File
File diff suppressed because it is too large Load Diff
+33 -36
View File
@@ -1,26 +1,7 @@
{
"name": "paseo",
"version": "0.1.63-beta.1",
"version": "0.1.60",
"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",
@@ -38,20 +19,14 @@
"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": "oxfmt .",
"format:files": "oxfmt",
"format:check": "oxfmt --check .",
"format:check:files": "oxfmt --check",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"knip": "knip",
"format": "biome format --write .",
"format:check": "biome format .",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -91,22 +66,44 @@
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"@biomejs/biome": "^2.4.8",
"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",
"playwright": "^1.56.1",
"typescript": "^5.9.3",
"ws": "^8.20.0"
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
},
"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"
}
}
@@ -37,16 +37,3 @@ 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
View File
@@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"
+5 -5
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 ({}, provide) => {
baseURL: async ({}, use) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
}
await provide(`http://localhost:${metroPort}`);
await use(`http://localhost:${metroPort}`);
},
});
@@ -67,7 +67,7 @@ test.beforeEach(async ({ page }) => {
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
({ daemon, preferences, seedNonce }) => {
// `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 === nonce) {
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
+267 -328
View File
@@ -8,13 +8,13 @@ import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
interface WaitForServerOptions {
type WaitForServerOptions = {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
}
};
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -125,21 +125,16 @@ 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");
}
settle();
resolve();
}, 5000);
child.once("exit", settle);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
@@ -202,12 +197,12 @@ function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
return resolvePaseoHomePath(trimmed);
}
interface OfferPayload {
type 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-"));
@@ -255,10 +250,8 @@ 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(ANSI_PATTERN, "");
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
function ensureRelayBuildArtifact(repoRoot: string): void {
@@ -335,44 +328,68 @@ async function waitForPairingOfferFromCli(args: {
);
}
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
async function loadEnvTestFile(repoRoot: string): Promise<void> {
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
}
async function applyPaseoHomeFork(targetHome: string): Promise<void> {
const port = await getAvailablePort();
let relayPort = 0;
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();
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
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(", ")}`,
if (forkSourceHome) {
const forkResult = await forkPaseoHomeMetadata({
sourceHome: forkSourceHome,
targetHome: paseoHome,
});
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 cleanup = async () => {
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;
}
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -395,291 +412,213 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
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 relayPort = await startRelay();
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
relayPort,
metroPort,
paseoHome,
fakeGhBinDir,
dictation,
buffer: daemonLineBuffer,
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,
});
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",
@@ -688,7 +627,7 @@ export default async function globalSetup() {
}),
waitForServer(metroPort, {
label: "Metro web server",
timeoutMs: 120000,
timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),
@@ -7,22 +7,22 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
export interface ScrollMetrics {
export type ScrollMetrics = {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
}
};
export interface SeededAgent {
export type SeededAgent = {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
}
};
export interface DaemonClientInstance {
export type DaemonClientInstance = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ export interface 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,15 +84,17 @@ export function createReplyTurn(label: string): {
};
}
interface DaemonClientConfig {
type 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(
+64 -79
View File
@@ -27,19 +27,19 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
}
const needsReset = await page.evaluate(
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
({ expectedEndpoint, expectedServerId }) => {
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 { serverId?: string; connections?: unknown };
if (entry?.serverId !== serverId) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: { type?: string; endpoint?: string }) =>
(c: any) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
@@ -47,8 +47,7 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
)
return true;
return !connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === endpoint,
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
);
} catch {
return true;
@@ -69,10 +68,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon: seededDaemon, preferences: seededPreferences }) => {
({ daemon, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
@@ -81,68 +80,6 @@ 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}`;
@@ -161,18 +98,66 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
if (serverId !== expectedServerId) {
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) {
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
`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)}).`,
);
}
assertDaemonConnections(connections, expectedEndpoint);
if (!snapshot.prefsRaw) {
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
assertPreferencesMatch(snapshot.prefsRaw, serverId);
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.");
}
}
export const gotoAppShell = async (page: Page) => {
@@ -323,7 +308,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
}
const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory;
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
await expect
.poll(
@@ -358,8 +343,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 Array<{ serverId?: string }>;
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;
+17 -15
View File
@@ -11,13 +11,13 @@ import {
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export interface ArchiveTabAgent {
export type ArchiveTabAgent = {
id: string;
title: string;
cwd: string;
}
};
interface ArchiveTabDaemonClient {
type ArchiveTabDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ interface ArchiveTabDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
}
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -73,15 +73,17 @@ function buildSeededStoragePayload() {
};
}
interface ArchiveTabDaemonClientConfig {
type 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(
@@ -148,21 +150,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: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
({ daemon, preferences, seedNonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === nonce) {
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon, preferences, seedNonce },
);
@@ -173,11 +175,11 @@ export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon: seededDaemon, preferences: seededPreferences }) => {
({ daemon, preferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
+1 -1
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.
}
+7 -5
View File
@@ -17,21 +17,21 @@ type NewWorkspaceDaemonClient = Pick<
| "openProject"
>;
interface NewWorkspaceDaemonClientConfig {
type NewWorkspaceDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
};
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export interface OpenedProject {
export type OpenedProject = {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
}
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -49,7 +49,9 @@ function getDaemonWsUrl(): string {
}
async function loadDaemonClientConstructor(): Promise<
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
new (
config: NewWorkspaceDaemonClientConfig,
) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
+12 -12
View File
@@ -1,20 +1,20 @@
import { WebSocket } from "ws";
import WebSocket from "ws";
interface WebSocketLike {
type WebSocketLike = {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
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;
}
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;
};
export type NodeWebSocketFactory = (
url: string,
+4 -4
View File
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
export interface PaseoHomeMetadataForkResult {
export type PaseoHomeMetadataForkResult = {
sourceHome: string;
targetHome: string;
agentFiles: number;
@@ -13,13 +13,13 @@ export interface PaseoHomeMetadataForkResult {
copiedFiles: number;
copiedBytes: number;
skippedMissing: string[];
}
};
interface CopyStats {
type CopyStats = {
files: number;
bytes: number;
skippedMissing: string[];
}
};
export function resolvePaseoHomePath(value: string): string {
if (value === "~") {
+8 -5
View File
@@ -6,11 +6,11 @@ const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const REGISTRY_KEY = "@paseo:daemon-registry";
const E2E_KEY = "@paseo:e2e";
interface SavedHostInput {
type SavedHostInput = {
serverId: string;
label: string;
endpoint: string;
}
};
export function startupScenario(page: Page) {
return new StartupScenario(page);
@@ -106,15 +106,18 @@ class StartupScenario {
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
await this.page.evaluate(
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
({ keys, registry, createAgentPreferences }) => {
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.registry, JSON.stringify(registry));
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify(createAgentPreferences),
);
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
},
{
+4 -4
View File
@@ -7,16 +7,16 @@ import {
type TerminalPerfDaemonClient,
} from "./terminal-perf";
interface TempRepo {
type TempRepo = {
path: string;
cleanup: () => Promise<void>;
}
};
export interface TerminalInstance {
export type TerminalInstance = {
id: string;
name: string;
cwd: string;
}
};
export class TerminalE2EHarness {
readonly client: TerminalPerfDaemonClient;
+14 -32
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 interface TerminalPerfDaemonClient {
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -37,7 +37,7 @@ export interface 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;
@@ -55,15 +55,17 @@ function getServerId(): string {
return serverId;
}
interface TerminalPerfDaemonClientConfig {
type 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(
@@ -100,19 +102,7 @@ function buildWorkspaceUrl(workspaceId: string): string {
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (
window as Window & {
__paseoTerminal?: {
buffer: {
active: {
length: number;
getLine: (i: number) => { translateToString: (trim: boolean) => string } | null;
};
};
onWriteParsed: (cb: () => void) => { dispose: () => void };
};
}
).__paseoTerminal;
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
@@ -203,10 +193,10 @@ export async function setupDeterministicPrompt(page: Page, sentinel?: string): P
await page.waitForTimeout(300);
}
export interface LatencySample {
export type LatencySample = {
char: string;
latencyMs: number;
}
};
/**
* Measures keystroke echo round-trip latency.
@@ -217,16 +207,12 @@ export interface LatencySample {
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const win = window as Window & {
__paseoTerminal?: { onWriteParsed: (cb: () => void) => { dispose: () => void } };
__perfKeystroke?: { promise: Promise<number> | null };
};
if (!win.__paseoTerminal) {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const term = win.__paseoTerminal;
const state = (win.__perfKeystroke = {
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
@@ -252,11 +238,7 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
await page.keyboard.press(char);
return page.evaluate(
() =>
(window as unknown as { __perfKeystroke: { promise: Promise<number> } }).__perfKeystroke
.promise,
);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
+35 -68
View File
@@ -1,6 +1,6 @@
import type { Page } from "@playwright/test";
export interface TerminalRenderProbeSnapshot {
export type TerminalRenderProbeSnapshot = {
setCount: number;
unsetCount: number;
writeCount: number;
@@ -10,28 +10,28 @@ export interface TerminalRenderProbeSnapshot {
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
}
};
export interface TerminalRenderProbeEvent {
export type TerminalRenderProbeEvent = {
at: number;
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
preview?: string;
}
};
export interface TerminalFrame {
export type 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 {
export type TerminalKeystrokeStressReport = {
inputTextLength: number;
keydownCount: number;
inputFrameCount: number;
@@ -70,25 +70,21 @@ export interface TerminalKeystrokeStressReport {
keydownToXtermCommitMs: LatencyStats | null;
firstKeydownAt: number | null;
lastXtermCommitAt: number | null;
}
};
export interface LatencyStats {
export type 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;
type ProbeState = {
term: any;
setCount: number;
unsetCount: number;
writeCount: number;
@@ -103,12 +99,9 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
reset: () => void;
snapshot: () => TerminalRenderProbeSnapshot;
startSampling: (durationMs: number) => void;
}
const win = window as unknown as Record<string, unknown> & {
__terminalRenderProbe?: ProbeState;
__paseoTerminal?: ProbeTerm;
};
const win = window as any;
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
@@ -189,7 +182,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
get() {
return probe.term;
},
set(next: ProbeTerm | undefined) {
set(next: any) {
if (next === undefined) {
probe.unsetCount += 1;
probe.events.push({ at: performance.now(), type: "unset" });
@@ -208,7 +201,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
probe.writeCount += 1;
const preview = text
.replaceAll("\u001b", "\\x1b")
.replace(/\u001b/g, "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.slice(0, 160);
@@ -237,30 +230,20 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
});
}
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();
(window as any).__terminalRenderProbe.reset();
});
}
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
await page.evaluate((ms) => {
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.startSampling(ms);
(window as any).__terminalRenderProbe.startSampling(ms);
}, durationMs);
}
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
return page.evaluate(() =>
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.snapshot(),
);
return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot());
}
export async function terminalVisibleText(page: Page): Promise<string> {
@@ -289,29 +272,29 @@ export function summarizeTerminalRenderProbe(
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
interface TimedTextEvent {
type TimedTextEvent = {
at: number;
text: string;
bytes: number;
}
interface TimedTextMessageEvent {
};
type TimedTextMessageEvent = {
at: number;
bytes: number;
kind: string | null;
}
interface XtermWriteEvent {
};
type XtermWriteEvent = {
at: number;
committedAt: number | null;
text: string;
bytes: number;
}
interface AppProbeEvent {
};
type AppProbeEvent = {
type: string;
at: number;
bytes?: number;
queueDepth?: number;
}
interface StressProbeState {
};
type StressProbeState = {
keydowns: Array<{ at: number; key: string }>;
inputFrames: TimedTextEvent[];
outputFrames: TimedTextEvent[];
@@ -320,7 +303,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
appEvents: AppProbeEvent[];
reset: () => void;
report: (inputText: string) => TerminalKeystrokeStressReport;
}
};
const INPUT_OPCODE = 0x02;
const OUTPUT_OPCODE = 0x01;
@@ -570,7 +553,8 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
lastXtermCommitAt:
this.xtermWrites
.map((write) => write.committedAt)
.findLast((at): at is number => typeof at === "number") ?? null,
.filter((at): at is number => typeof at === "number")
.at(-1) ?? null,
};
},
};
@@ -608,7 +592,6 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
return;
});
void eventDataText(event.data).then((text) => {
if (text === null) {
@@ -619,12 +602,11 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
bytes: new TextEncoder().encode(text).byteLength,
kind: textMessageKind(text),
});
return;
});
});
}
send(data: Parameters<WebSocket["send"]>[0]): void {
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
const bytes = bytesFrom(data);
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
probe.inputFrames.push({
@@ -655,10 +637,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
get() {
return terminal;
},
set(next: {
write?: (data: string | Uint8Array, callback?: () => void) => void;
__paseoKeystrokeProbeWriteWrapped?: boolean;
}) {
set(next: any) {
terminal = next;
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
@@ -684,18 +663,9 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
});
}
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();
(window as any).__terminalKeystrokeStressProbe.reset();
});
}
@@ -704,10 +674,7 @@ export async function readTerminalKeystrokeStressReport(
inputText: string,
): Promise<TerminalKeystrokeStressReport> {
return page.evaluate(
(text) =>
(
window as unknown as TerminalKeystrokeStressProbeWindow
).__terminalKeystrokeStressProbe.report(text),
(text) => (window as any).__terminalKeystrokeStressProbe.report(text),
inputText,
);
}
+3 -3
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";
interface WorkspaceSetupDaemonClient {
type WorkspaceSetupDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -52,7 +52,7 @@ interface 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("/").findLast(Boolean) ?? repoPath;
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
+2 -2
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";
interface TempRepo {
type TempRepo = {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
}
};
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
+1
View File
@@ -17,6 +17,7 @@ import {
} from "./helpers/launcher";
import {
connectTerminalClient,
waitForTerminalContent,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";
+15 -21
View File
@@ -7,13 +7,13 @@ import { waitForSidebarHydration } from "./helpers/workspace-ui";
type WireDirection = "sent" | "received";
type WirePhase = "startup" | "workspace_clicks";
interface ParsedWireMessage {
type ParsedWireMessage = {
type: string | null;
requestId: string | null;
entryCount: number | null;
hasMore: boolean | null;
providerEntries: ProviderSnapshotWireEntry[] | null;
}
};
type WireFrameRecord = ParsedWireMessage & {
phase: WirePhase;
@@ -21,23 +21,23 @@ type WireFrameRecord = ParsedWireMessage & {
bytes: number;
};
interface ProviderSnapshotWireEntry {
type ProviderSnapshotWireEntry = {
provider: string;
status: string | null;
modelCount: number;
modeCount: number;
bytes: number;
}
};
interface WebSocketFrameEvent {
type WebSocketFrameEvent = {
requestId: string;
response: {
opcode: number;
payloadData: string;
};
}
};
interface WireSummary {
type WireSummary = {
totalFrames: number;
totalBytes: number;
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
@@ -84,18 +84,7 @@ interface WireSummary {
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";
@@ -379,7 +368,7 @@ function summarizeByType(records: WireFrameRecord[]): Array<{
byType.set(type, current);
}
return [...byType.entries()]
.map(([type, value]) => Object.assign({ type }, value))
.map(([type, value]) => ({ type, ...value }))
.sort((left, right) => right.bytes - left.bytes);
}
@@ -510,7 +499,12 @@ test.describe("ad hoc startup wire metrics", () => {
const workspaceTestIds = await page
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
.evaluateAll(extractWorkspaceTestIds);
.evaluateAll((elements) =>
elements
.slice(0, 3)
.map((element) => element.getAttribute("data-testid"))
.filter((value): value is string => Boolean(value)),
);
monitor.setPhase("workspace_clicks");
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
@@ -45,7 +45,6 @@ async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout:
null,
2,
)}`,
{ cause: error },
);
}
@@ -79,10 +78,7 @@ test.describe("Terminal alternate-screen transitions", () => {
`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 waitForTerminalContent(page, (text) => text.includes(historyReady), 10_000);
await resetTerminalRenderProbe(page);
await page.waitForTimeout(500);
@@ -121,16 +117,14 @@ test.describe("Terminal alternate-screen transitions", () => {
expect(finalBufferText).toContain(historyReady);
expect(finalBufferText).toContain(afterAlt);
function isSuspiciousFrame(frame: (typeof probe.frames)[number]): boolean {
return (
const suspiciousFrames = probe.frames.filter(
(frame) =>
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);
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1,
);
expect(
suspiciousFrames,
@@ -17,7 +17,7 @@ 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 {
type DaemonEchoReport = {
inputTextLength: number;
inputFrameCount: number;
outputEventCount: number;
@@ -25,7 +25,7 @@ interface DaemonEchoReport {
sendToOutputMs: LatencyStats;
firstSendAt: number;
lastOutputAt: number;
}
};
terminalPerfDescribe("Terminal keystroke stress", () => {
let harness: TerminalE2EHarness;
@@ -140,12 +140,12 @@ test.describe("Workspace navigation regression", () => {
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.evaluate(
({ agentId, serverId: targetServerId }) => {
({ agentId, serverId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
serverId,
agentId,
reason: "finished",
},
@@ -19,7 +19,7 @@ function getServerId(): string {
return serverId;
}
interface WorkspaceScriptStarter {
type WorkspaceScriptStarter = {
startWorkspaceScript(
workspaceId: string,
scriptName: string,
@@ -29,7 +29,7 @@ interface WorkspaceScriptStarter {
terminalId: string | null;
error: string | null;
}>;
}
};
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
@@ -353,11 +353,10 @@ test.describe("Workspace setup streaming", () => {
error: null,
});
const findEditorTerminal = (terminal: { name: string }) => terminal.name === "editor";
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspaceDir);
return terminals.terminals.find(findEditorTerminal) ?? null;
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
})
.toMatchObject({
id: expect.any(String),
+1 -2
View File
@@ -39,8 +39,7 @@
"ascAppId": "6758887924"
},
"android": {
"track": "production",
"releaseStatus": "completed"
"releaseStatus": "draft"
}
}
}
-1
View File
@@ -1 +0,0 @@
app_identifier("sh.paseo")
-42
View File
@@ -1,42 +0,0 @@
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
View File
@@ -1 +0,0 @@
declare module "*.css";
@@ -24,3 +24,4 @@ appId: sh.paseo
commands:
- tapOn:
point: "50%,20%"
+1
View File
@@ -3,3 +3,4 @@ appId: sh.paseo
- launchApp:
clearState: true
- runFlow: flows/dev-client.yaml
+1 -1
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"),
+18 -12
View File
@@ -1,8 +1,8 @@
{
"name": "@getpaseo/app",
"version": "0.1.63-beta.1",
"private": true,
"main": "index.ts",
"version": "0.1.60",
"private": true,
"scripts": {
"start": "APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
@@ -17,7 +17,7 @@
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsgo --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test",
@@ -30,14 +30,18 @@
"@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.63-beta.1",
"@getpaseo/highlight": "0.1.63-beta.1",
"@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60",
"@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",
@@ -50,10 +54,11 @@
"@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",
@@ -61,6 +66,7 @@
"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",
@@ -70,30 +76,34 @@
"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"
@@ -101,16 +111,12 @@
"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",
+29
View File
@@ -0,0 +1,29 @@
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"] },
},
],
});
@@ -0,0 +1,29 @@
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"] },
},
],
});
+14 -15
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="%LANG_ISO_CODE%">
<head>
<meta charset="utf-8" />
@@ -21,9 +21,8 @@
}
/* 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 */
@@ -39,17 +38,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;
}
@@ -60,7 +59,7 @@
outline: none;
}
*:focus-visible {
outline: 2px solid #20744a;
outline: 2px solid #20744A;
outline-offset: 2px;
}
</style>
+125 -142
View File
@@ -1,8 +1,5 @@
import "@/styles/unistyles";
import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { polyfillCrypto } from "@/polyfills/crypto";
import {
Stack,
useGlobalSearchParams,
@@ -10,98 +7,118 @@ 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 { useStableEvent } from "@/hooks/use-stable-event";
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 { 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 { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
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 { SessionProvider } from "@/contexts/session-context";
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 { 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 { updateDesktopWindowControls } from "@/desktop/electron/window";
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 {
getHostRuntimeStore,
useHostMutations,
useHostRuntimeClient,
useHosts,
} from "@/runtime/host-runtime";
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 {
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import {
buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
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";
import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto();
export interface HostRuntimeBootstrapState {
export type 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>({
@@ -175,7 +192,6 @@ function PushNotificationRouter() {
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
@@ -225,7 +241,6 @@ function PushNotificationRouter() {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
@@ -484,8 +499,7 @@ function AppContainer({
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateCalloutSource />
<DaemonVersionMismatchCalloutSource />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
@@ -664,10 +678,9 @@ function OfferLinkListener({
void upsertDaemonFromOfferUrl(url)
.then((profile) => {
if (cancelled) return;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId));
return;
})
.catch((error) => {
if (cancelled) return;
@@ -739,7 +752,6 @@ function OpenProjectListener() {
if (!disposed && pending) {
maybeOpenProject(pending);
}
return;
})
.catch(() => undefined);
@@ -757,7 +769,6 @@ function OpenProjectListener() {
return;
}
unlisten = dispose;
return;
})
.catch(() => undefined);
@@ -823,23 +834,19 @@ 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={stackScreenOptions}>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
@@ -855,7 +862,7 @@ function RootStack() {
outside this route-level native-stack API.
*/}
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
@@ -887,59 +894,35 @@ 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={gestureRootStyle}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</GestureHandlerRootView>
);
}
@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useLocalSearchParams, useRouter } 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 Href);
router.replace("/" as any);
return;
}
@@ -55,7 +55,7 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as Href,
}) as any,
);
}
}, [agentId, resolvedWorkspaceId, router, serverId]);
@@ -107,12 +107,11 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId,
target: { kind: "agent", agentId },
}) as Href,
}) as any,
);
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {
@@ -65,8 +65,8 @@ function stripOpenSearchParamFromBrowserUrl() {
}
function clearConsumedOpenIntent(input: {
navigation: { setParams: (params: { open?: string | undefined }) => void };
router: ReturnType<typeof useRouter>;
navigation: { setParams: (...args: any[]) => void };
router: { replace: (...args: any[]) => void };
serverId: string;
workspaceId: string;
}) {
@@ -137,9 +137,7 @@ function HostWorkspaceLayoutContent() {
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
navigation,
router,
serverId,
workspaceId,
@@ -163,9 +161,7 @@ function HostWorkspaceLayoutContent() {
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
navigation,
router,
serverId,
workspaceId,
+19 -35
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useLocalSearchParams, useRouter } 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, BarcodeSettings } from "expo-camera";
import type { BarcodeScanningResult } 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 Href);
router.replace("/" as any);
}
}, [router]);
@@ -198,29 +198,15 @@ 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={handleRouterBack} />
<View style={bodyStyle}>
<BackHeader title="Scan QR" onBack={() => router.back()} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<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>
@@ -237,14 +223,14 @@ export default function PairScanScreen() {
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={closeToSource} />
<View style={bodyStyle}>
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
{!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={handleRequestPermission}>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -253,17 +239,21 @@ export default function PairScanScreen() {
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={handleScan}
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.scanFrame}>
<View style={CORNER_TL_STYLE} />
<View style={CORNER_TR_STYLE} />
<View style={CORNER_BL_STYLE} />
<View style={CORNER_BR_STYLE} />
<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>
{isPairing ? <Text style={helperTextStyle}>Pairing…</Text> : null}
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing…
</Text>
) : null}
</View>
</View>
)}
@@ -271,9 +261,3 @@ 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];
+1 -3
View File
@@ -1,5 +1,4 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
@@ -7,7 +6,6 @@ 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={view} />;
return <SettingsScreen view={{ kind: "section", section }} />;
}
@@ -1,16 +1,14 @@
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={view} />
<SettingsScreen view={{ kind: "host", serverId }} />
</HostRouteBootstrapBoundary>
);
}
+1 -3
View File
@@ -3,8 +3,6 @@ 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();
@@ -12,5 +10,5 @@ export default function SettingsIndexRoute() {
return <Redirect href={buildSettingsSectionRoute("general")} />;
}
return <SettingsScreen view={ROOT_VIEW} />;
return <SettingsScreen view={{ kind: "root" }} />;
}
@@ -114,14 +114,12 @@ export function createLocalFileAttachmentStore(params: {
await ensureDirectory(baseDirectory);
const id = input.id ?? generateAttachmentId();
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 mimeTypeFromSource =
input.source.kind === "data_url"
? parseDataUrl(input.source.dataUrl).mimeType
: input.source.kind === "blob"
? input.source.blob.type
: undefined;
const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource);
const fileName = input.fileName ?? null;
const extension = extensionForAttachment({ fileName, mimeType });
+3 -2
View File
@@ -7,8 +7,9 @@ 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();
}
@@ -7,37 +7,29 @@ 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 = current ?? null;
if (!current) {
activeAttachmentRef.current = attachment ?? null;
if (!attachment) {
setUrl(null);
return;
}
void (async () => {
try {
const resolved = await resolveAttachmentPreviewUrl(current);
const resolved = await resolveAttachmentPreviewUrl(attachment);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
await releaseAttachmentPreviewUrl({ attachment, url: resolved });
return;
}
currentUrl = resolved;
setUrl(resolved);
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: current.id,
attachmentId: attachment.id,
error,
});
if (!disposed) {
@@ -57,7 +49,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [id, storageType, storageKey, mimeType]);
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
return url;
}
+8 -14
View File
@@ -55,10 +55,9 @@ export function parseImageDataUrl(
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
return null;
}
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
};
} catch {
return null;
@@ -81,24 +80,19 @@ export function getFileNameFromPath(path: string | null | undefined): string | n
}
export function createPreviewAttachmentId(input: {
base64: string;
mimeType: string;
path?: string | null;
size?: number | null;
modifiedAt?: string | null;
contentLength?: number | null;
}): string {
const path = input.path?.trim() ?? "";
const size = Number.isFinite(input.size) ? String(input.size) : "";
const modifiedAt = input.modifiedAt?.trim() ?? "";
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
return `preview_${size || contentLength || "unknown"}_${hash}`;
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
return `preview_${input.base64.length}_${hash}`;
}
export async function blobToBase64(blob: Blob): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected FileReader result while encoding attachment."));
return;
@@ -109,10 +103,10 @@ export async function blobToBase64(blob: Blob): Promise<string> {
return;
}
resolve(payload);
});
reader.addEventListener("error", () => {
};
reader.onerror = () => {
reject(reader.error ?? new Error("Failed to read attachment blob."));
});
};
reader.readAsDataURL(blob);
});
}
@@ -10,12 +10,12 @@ import {
parseDataUrl,
} from "@/attachments/utils";
interface StoredBlobRecord {
type StoredBlobRecord = {
id: string;
blob: Blob;
createdAt: number;
fileName: string | null;
}
};
const DB_NAME = "paseo-attachment-bytes";
const STORE_NAME = "attachments";
@@ -40,13 +40,13 @@ function openAttachmentDb(): Promise<IDBDatabase> {
}
};
request.addEventListener("success", () => {
request.onsuccess = () => {
resolve(request.result);
});
};
request.addEventListener("error", () => {
request.onerror = () => {
reject(request.error ?? new Error("Failed to open attachment IndexedDB."));
});
};
});
}
@@ -60,17 +60,17 @@ function runTx<T>(
const store = transaction.objectStore(STORE_NAME);
const request = run(store);
request.addEventListener("success", () => {
request.onsuccess = () => {
resolve(request.result as T);
});
};
request.addEventListener("error", () => {
request.onerror = () => {
reject(request.error ?? new Error("IndexedDB transaction request failed."));
});
};
transaction.addEventListener("error", () => {
transaction.onerror = () => {
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
});
};
});
}
@@ -203,13 +203,13 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const store = tx.objectStore(STORE_NAME);
const cursorRequest = store.openCursor();
cursorRequest.addEventListener("error", () => {
cursorRequest.onerror = () => {
reject(
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
);
});
};
cursorRequest.addEventListener("success", () => {
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) {
resolve();
@@ -221,11 +221,11 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
cursor.delete();
}
cursor.continue();
});
};
tx.addEventListener("error", () => {
tx.onerror = () => {
reject(tx.error ?? new Error("Failed to garbage collect IndexedDB attachments."));
});
};
});
} finally {
db.close();
@@ -0,0 +1,202 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
export interface ActiveProcessesProps {
agents: Agent[];
viewMode: "orchestrator" | "agent";
activeAgentId: string | null;
onSelectAgent: (serverId: string, id: string) => void;
onSelectOrchestrator: () => void;
}
function getAgentStatusColor(status: Agent["status"]): string {
switch (status) {
case "initializing":
return "#f59e0b";
case "idle":
return "#22c55e";
case "running":
return "#3b82f6";
case "error":
return "#ef4444";
case "closed":
return "#9ca3af";
default:
return "#9ca3af";
}
}
function getModeName(modeId?: string, availableModes?: Agent["availableModes"]): string {
if (!modeId) return "unknown";
const mode = availableModes?.find((m) => m.id === modeId);
return mode?.label || modeId;
}
function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
return "#3b82f6"; // blue - build/auto
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
return "#22c55e"; // green - safest
return "#9ca3af"; // gray - unknown
}
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.surface2,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButton: {
backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
backButtonActive: {
backgroundColor: theme.colors.palette.zinc[700],
},
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "600",
textAlign: "center",
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
processItemActive: {
backgroundColor: theme.colors.primary,
},
processItemInactive: {
backgroundColor: theme.colors.surface2,
},
agentIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[500],
},
commandIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.palette.purple[500],
},
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: "500",
},
processTextActive: {
color: theme.colors.primaryForeground,
},
statusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
modeIndicator: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
opacity: 0.3,
},
}));
export function ActiveProcesses({
agents,
viewMode,
activeAgentId,
onSelectAgent,
onSelectOrchestrator,
}: ActiveProcessesProps) {
// Only show if there's at least one agent
if (agents.length === 0) {
return null;
}
return (
<View style={styles.container}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={{ gap: 8 }}
>
{/* Orchestrator pill */}
<Pressable
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
>
Orchestrator
</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const isActive = viewMode === "agent" && activeAgentId === agent.id;
return (
<Pressable
key={`agent-${agent.id}`}
onPress={() => onSelectAgent(agent.serverId, agent.id)}
style={({ pressed }) => [
styles.processItem,
isActive ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={[styles.processText, isActive && styles.processTextActive]}>
{agent.id.substring(0, 8)}
</Text>
<View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
{agent.currentModeId && (
<View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
)}
</Pressable>
);
})}
</ScrollView>
</View>
);
}
@@ -1,5 +1,5 @@
import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode, Ref } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
@@ -24,7 +24,6 @@ import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
function handleEscKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
@@ -144,18 +143,18 @@ const styles = StyleSheet.create((theme) => ({
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
() => [
style,
{
backgroundColor: theme.colors.surface1,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
},
],
[style, theme.colors.surface1, theme.borderRadius.xl],
return (
<View
style={[
style,
{
backgroundColor: theme.colors.surface1,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
},
]}
/>
);
return <View style={combinedStyle} />;
}
export interface AdaptiveModalSheetProps {
@@ -192,10 +191,6 @@ export function AdaptiveModalSheet({
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
@@ -209,12 +204,6 @@ export function AdaptiveModalSheet({
[],
);
const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
);
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
@@ -231,14 +220,14 @@ export function AdaptiveModalSheet({
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title}
</Text>
{subtitle}
@@ -267,7 +256,7 @@ export function AdaptiveModalSheet({
<>
<View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title}
</Text>
{subtitle}
@@ -294,8 +283,12 @@ export function AdaptiveModalSheet({
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable accessibilityLabel="Dismiss" style={ABSOLUTE_FILL_STYLE} onPress={onClose} />
<View style={desktopCardStyle}>
<Pressable
accessibilityLabel="Dismiss"
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
) : (
@@ -333,7 +326,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
const isMobile = useIsCompactFormFactor();
if (isMobile) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
return <BottomSheetTextInput ref={ref as any} {...props} />;
}
return <TextInput ref={ref} {...props} />;
+16 -40
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -10,8 +10,6 @@ import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -160,11 +158,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
hostInputRef.current?.clear();
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
);
const handleClose = useCallback(() => {
if (isSaving) return;
clearInput();
@@ -221,15 +214,13 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error);
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
} else if (detail) {
combined = `${title}\n${detail}`;
} else {
combined = title;
}
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
const combined =
raw && detail && raw !== detail
? `${title}\n${detail}\nDetails: ${raw}`
: detail
? `${title}\n${detail}`
: title;
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
@@ -240,18 +231,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
const handleChangeEndpoint = useCallback((next: string) => {
endpointRawRef.current = next;
}, []);
const handleSubmitEditing = useCallback(() => {
void handleSave();
}, [handleSave]);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
return (
<AdaptiveModalSheet
title="Direct connection"
@@ -268,7 +247,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={handleChangeEndpoint}
onChangeText={(next) => {
endpointRawRef.current = next;
}}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -277,26 +258,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
onSubmitEditing={() => void handleSave()}
/>
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
variant="secondary"
onPress={handleCancel}
disabled={isSaving}
>
<Button style={{ flex: 1 }} variant="secondary" onPress={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button
style={FLEX_ONE_STYLE}
style={{ flex: 1 }}
variant="default"
onPress={handleSavePress}
onPress={() => void handleSave()}
disabled={isSaving}
leftIcon={connectIcon}
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
testID="direct-host-submit"
>
{isSaving ? "Connecting..." : "Connect"}
@@ -0,0 +1,403 @@
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type {
AgentActivity,
GroupedTextMessage,
MergedToolCall,
SessionUpdate,
} from "@/types/agent-activity";
interface AgentActivityItemProps {
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
function getToolIcon(toolKind?: string): string {
switch (toolKind) {
case "read":
return "📖";
case "edit":
return "✏️";
case "delete":
return "🗑️";
case "move":
return "📦";
case "search":
return "🔍";
case "execute":
return "▶️";
case "think":
return "💭";
case "fetch":
return "🌐";
case "switch_mode":
return "🔄";
default:
return "🔧";
}
}
function getStatusColor(status?: string): string {
switch (status) {
case "pending":
return "#9ca3af";
case "in_progress":
return "#fbbf24";
case "completed":
return "#22c55e";
case "failed":
return "#ef4444";
default:
return "#6b7280";
}
}
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === "thought";
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
</View>
);
}
function MergedToolCallItem({ item }: { item: MergedToolCall }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<View style={stylesheet.toolCard}>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
<View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.toolContent}>
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
</View>
)}
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
</View>
)}
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>No details available</Text>
)}
</View>
)}
</View>
);
}
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== "plan") {
return null;
}
return (
<View style={stylesheet.planCard}>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}>
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
</Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View>
))}
</View>
)}
</View>
);
}
function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [showDrawer, setShowDrawer] = useState(false);
return (
<View style={stylesheet.unknownCard}>
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
</View>
</Pressable>
{showDrawer && (
<View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
</View>
)}
</View>
);
}
export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message
if ("kind" in item && item.kind === "grouped_text") {
return <GroupedTextItem item={item} />;
}
// Merged tool call
if ("kind" in item && item.kind === "merged_tool_call") {
return <MergedToolCallItem item={item} />;
}
// Individual activity
const activity = item as AgentActivity;
const update = activity.update;
// Tasks
if (update.kind === "plan") {
return <PlanItem update={update} timestamp={activity.timestamp} />;
}
// Available commands update
if (update.kind === "available_commands_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>
Commands updated ({update.availableCommands.length} available)
</Text>
</View>
);
}
// Current mode update
if (update.kind === "current_mode_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
</View>
);
}
// Unknown activity type
return <UnknownActivityItem update={update} timestamp={activity.timestamp} />;
}
const stylesheet = StyleSheet.create((theme) => ({
card: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
},
thoughtCard: {
backgroundColor: theme.colors.surface2,
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.purple[500],
paddingVertical: theme.spacing[2],
},
timestamp: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
thoughtTimestamp: {
marginBottom: theme.spacing[0],
},
thoughtLabel: {
color: theme.colors.palette.purple[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[0],
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
thoughtText: {
color: theme.colors.foregroundMuted,
fontStyle: "italic",
},
toolCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
toolHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
toolHeaderLeft: {
flex: 1,
},
toolTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
toolIcon: {
fontSize: theme.fontSize.base,
},
toolTitle: {
color: theme.colors.palette.blue[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
flex: 1,
},
statusBadge: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
statusText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
expandIcon: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
toolContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
padding: theme.spacing[3],
},
section: {
marginBottom: theme.spacing[3],
},
sectionTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
code: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
backgroundColor: theme.colors.surface2,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
planCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
planHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
planHeaderLeft: {
flex: 1,
},
planTitle: {
color: theme.colors.palette.green[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
marginTop: theme.spacing[1],
},
planContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
padding: theme.spacing[3],
},
planEntry: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
planEntryStatus: {
fontSize: theme.fontSize.sm,
marginTop: 2,
},
planEntryText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flex: 1,
},
infoText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
unknownCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
unknownHeader: {
padding: theme.spacing[3],
},
unknownBadge: {
backgroundColor: theme.colors.palette.orange[600],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2],
alignSelf: "flex-start",
},
unknownBadgeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
drawerContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
padding: theme.spacing[3],
backgroundColor: theme.colors.surface2,
},
}));
File diff suppressed because it is too large Load Diff
+62 -83
View File
@@ -6,11 +6,10 @@ import {
RefreshControl,
FlatList,
type ListRenderItem,
type PressableStateCallbackType,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router, type Href } from "expo-router";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
@@ -150,26 +149,24 @@ function SessionBadge({
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
const badgeStyle = useMemo(
() => [
styles.badge,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
],
[tone],
);
const badgeTextStyle = useMemo(
() => [
styles.badgeText,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
],
[tone],
);
return (
<View style={badgeStyle}>
<View
style={[
styles.badge,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
]}
>
{icon}
<Text style={badgeTextStyle}>{label}</Text>
<Text
style={[
styles.badgeText,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
]}
>
{label}
</Text>
</View>
);
}
@@ -197,34 +194,16 @@ function SessionRow({
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
isSelected && styles.rowSelected,
Boolean(hovered) && styles.rowHovered,
pressed && styles.rowPressed,
],
[isSelected],
);
const handlePress = useCallback(() => onPress(agent), [onPress, agent]);
const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]);
const sessionTitleStyle = useMemo(
() => [styles.sessionTitle, isSelected && styles.sessionTitleHighlighted],
[isSelected],
);
const archivedIcon = useMemo(
() => <Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />,
[theme.fontSize.xs, theme.colors.foregroundMuted],
);
return (
<Pressable
style={pressableStyle}
onPress={handlePress}
onLongPress={handleLongPress}
style={({ pressed, hovered }) => [
styles.row,
isSelected && styles.rowSelected,
hovered && styles.rowHovered,
pressed && styles.rowPressed,
]}
onPress={() => onPress(agent)}
onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.rowContent}>
@@ -232,10 +211,18 @@ function SessionRow({
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={sessionTitleStyle} numberOfLines={1}>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
{agent.title || "New session"}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null}
{agent.archivedAt ? (
<SessionBadge
label="Archived"
icon={<Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
) : null}
@@ -319,7 +306,7 @@ export function AgentList({
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as Href);
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return;
}
@@ -415,32 +402,6 @@ export function AgentList({
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
const refreshColors = useMemo(
() => [theme.colors.foregroundMuted],
[theme.colors.foregroundMuted],
);
const sheetContainerStyle = useMemo(
() => [styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }],
[insets.bottom, theme.spacing],
);
const sheetArchiveTextStyle = useMemo(
() => [styles.sheetArchiveText, isActionDaemonUnavailable && styles.sheetArchiveTextDisabled],
[isActionDaemonUnavailable],
);
const refreshControl = useMemo(
() =>
onRefresh ? (
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={refreshColors}
/>
) : undefined,
[onRefresh, isRefreshing, theme.colors.foregroundMuted, refreshColors],
);
return (
<>
<FlatList
@@ -452,7 +413,16 @@ export function AgentList({
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListFooterComponent={listFooterComponent}
refreshControl={refreshControl}
refreshControl={
onRefresh ? (
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={[theme.colors.foregroundMuted]}
/>
) : undefined
}
/>
<Modal
@@ -463,7 +433,12 @@ export function AgentList({
>
<View style={styles.sheetOverlay}>
<Pressable style={styles.sheetBackdrop} onPress={handleCloseActionSheet} />
<View style={sheetContainerStyle}>
<View
style={[
styles.sheetContainer,
{ paddingBottom: Math.max(insets.bottom, theme.spacing[6]) },
]}
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable
@@ -472,7 +447,7 @@ export function AgentList({
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
style={SHEET_CANCEL_BUTTON_STYLE}
style={[styles.sheetButton, styles.sheetCancelButton]}
onPress={handleCloseActionSheet}
testID="agent-action-cancel"
>
@@ -480,11 +455,18 @@ export function AgentList({
</Pressable>
<Pressable
disabled={isActionDaemonUnavailable}
style={SHEET_ARCHIVE_BUTTON_STYLE}
style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveAgent}
testID="agent-action-archive"
>
<Text style={sheetArchiveTextStyle}>Archive</Text>
<Text
style={[
styles.sheetArchiveText,
isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
]}
>
Archive
</Text>
</Pressable>
</View>
</View>
@@ -695,6 +677,3 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
},
}));
const SHEET_CANCEL_BUTTON_STYLE = [styles.sheetButton, styles.sheetCancelButton];
const SHEET_ARCHIVE_BUTTON_STYLE = [styles.sheetButton, styles.sheetArchiveButton];
File diff suppressed because it is too large Load Diff
@@ -37,72 +37,6 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
}
}
function findModelById(
models: AgentModelDefinition[] | null,
modelId: string | null,
): AgentModelDefinition | null {
if (!models || !modelId) {
return null;
}
return models.find((model) => model.id === modelId) ?? null;
}
function getFallbackModel(models: AgentModelDefinition[] | null): AgentModelDefinition | null {
return models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
}
function resolvePreferredModelId(
runtimeSelectedModel: AgentModelDefinition | null,
normalizedConfiguredModelId: string | null,
normalizedRuntimeModelId: string | null,
): string | null {
return runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
}
function pickSelectedModel(
models: AgentModelDefinition[] | null,
preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null,
): AgentModelDefinition | null {
if (!models || !preferredModelId) {
return fallbackModel;
}
return findModelById(models, preferredModelId) ?? fallbackModel;
}
function resolveThinkingId(
explicitThinkingOptionId: string | null | undefined,
selectedModel: AgentModelDefinition | null,
): string | null {
if (explicitThinkingOptionId && explicitThinkingOptionId !== "default") {
return explicitThinkingOptionId;
}
return selectedModel?.defaultThinkingOptionId ?? null;
}
type ThinkingOption = NonNullable<AgentModelDefinition["thinkingOptions"]>[number];
function resolveEffectiveThinking(
thinkingOptions: ThinkingOption[] | null,
resolvedThinkingId: string | null,
): ThinkingOption | null {
const selectedThinking =
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
return selectedThinking ?? thinkingOptions?.[0] ?? null;
}
function resolveModelDisplay(
selectedModel: AgentModelDefinition | null,
preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null,
): { activeModelId: string | null; displayModel: string } {
return {
activeModelId: selectedModel?.id ?? preferredModelId ?? null,
displayModel:
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model",
};
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
@@ -112,25 +46,30 @@ export function resolveAgentModelSelection(input: {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const runtimeSelectedModel =
models && normalizedRuntimeModelId
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
: null;
const preferredModelId =
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
const selectedModel =
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
: fallbackModel;
const runtimeSelectedModel = findModelById(models, normalizedRuntimeModelId);
const preferredModelId = resolvePreferredModelId(
runtimeSelectedModel,
normalizedConfiguredModelId,
normalizedRuntimeModelId,
);
const fallbackModel = getFallbackModel(models);
const selectedModel = pickSelectedModel(models, preferredModelId, fallbackModel);
const { activeModelId, displayModel } = resolveModelDisplay(
selectedModel,
preferredModelId,
fallbackModel,
);
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel =
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const resolvedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -42,12 +41,7 @@ export function AgentStatusDot({
return null;
}
return <AgentStatusDotView color={color} />;
}
function AgentStatusDotView({ color }: { color: string }) {
const dotStyle = useMemo(() => [styles.dot, { backgroundColor: color }], [color]);
return <View style={dotStyle} />;
return <View style={[styles.dot, { backgroundColor: color }]} />;
}
function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
@@ -11,37 +11,37 @@ import {
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
export interface StreamRenderSegments {
export type StreamRenderSegments = {
historyVirtualized: StreamItem[];
historyMounted: StreamItem[];
liveHead: StreamItem[];
}
};
export interface StreamHistoryBoundary {
export type StreamHistoryBoundary = {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
}
};
export interface StreamRenderAuxiliary {
export type StreamRenderAuxiliary = {
pendingPermissions: ReactNode;
workingIndicator: ReactNode;
}
};
export interface AgentStreamRenderModel {
export type AgentStreamRenderModel = {
history: StreamItem[];
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary;
}
};
export interface BuildAgentStreamRenderModelInput {
export type BuildAgentStreamRenderModelInput = {
tail: StreamItem[];
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
}
};
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
+243 -404
View File
@@ -7,17 +7,8 @@ import {
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
@@ -258,10 +249,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
[
agent.cwd,
agent.projectPlacement?.checkout?.isGit,
isMobile,
openFileExplorerForCheckout,
onOpenWorkspaceFile,
requestDirectoryListing,
resolvedServerId,
router,
@@ -292,9 +281,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[],
);
const scrollToBottom = useCallback(() => {
function scrollToBottom() {
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
}
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
@@ -329,182 +318,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[assistantBlockGap, looseGap, tightGap],
);
const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
return;
}
setExpandedInlineToolCallIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
},
[streamRenderStrategy],
);
const renderUserMessageItem = useCallback(
(
item: Extract<StreamItem, { kind: "user_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isFirstInGroup = aboveItem?.kind !== "user_message";
const isLastInGroup = belowItem?.kind !== "user_message";
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
/>
);
},
[streamRenderStrategy],
);
const renderAssistantMessageItem = useCallback(
(
item: Extract<StreamItem, { kind: "assistant_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
spacing={spacing}
/>
);
},
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client],
);
const renderThoughtItem = useCallback(
(item: Extract<StreamItem, { kind: "thought" }>, index: number, items: StreamItem[]) => {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
/>
);
},
[streamRenderStrategy, setInlineDetailsExpanded],
);
const renderToolCallItem = useCallback(
(item: Extract<StreamItem, { kind: "tool_call" }>, index: number, items: StreamItem[]) => {
const { payload } = item;
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
if (payload.source === "agent") {
const data = payload.data;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return (
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
);
}
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.name}
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
/>
);
}
const data = payload.data;
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
);
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -512,18 +325,153 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
items: StreamItem[],
seamAboveItem: StreamItem | null = null,
) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
return;
}
setExpandedInlineToolCallIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(item.id);
} else {
next.delete(item.id);
}
return next;
});
};
switch (item.kind) {
case "user_message":
return renderUserMessageItem(item, index, items, seamAboveItem);
case "user_message": {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isFirstInGroup = aboveItem?.kind !== "user_message";
const isLastInGroup = belowItem?.kind !== "user_message";
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
/>
);
}
case "assistant_message":
return renderAssistantMessageItem(item, index, items, seamAboveItem);
case "assistant_message": {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
spacing={spacing}
/>
);
}
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<ToolCall
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
case "thought":
return renderThoughtItem(item, index, items);
case "tool_call": {
const { payload } = item;
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
case "tool_call":
return renderToolCallItem(item, index, items);
if (payload.source === "agent") {
const data = payload.data;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return (
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
);
}
return (
<ToolCall
toolName={data.name}
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
const data = payload.data;
return (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
case "activity_log":
return (
@@ -545,7 +493,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null;
}
},
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
[handleInlinePathPress, agent.cwd, streamRenderStrategy],
);
const renderStreamItem = useCallback(
@@ -571,18 +519,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const getTurnContent = () =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy: streamRenderStrategy,
items,
startIndex: index,
});
return (
<StreamItemWrapper gapBelow={gapBelow}>
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
{content}
{isEndOfAssistantTurn ? (
<TurnCopyButtonSlot
strategy={streamRenderStrategy}
items={items}
startIndex={index}
/>
) : null}
</StreamItemWrapper>
{isEndOfAssistantTurn ? <TurnCopyButton getContent={getTurnContent} /> : null}
</View>
);
},
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
@@ -631,7 +579,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
};
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(() => {
if (
renderModel.boundary.hasVirtualizedHistory ||
@@ -644,14 +591,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return (
<View style={emptyStateStyle}>
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}, [renderModel, emptyStateStyle]);
}, [renderModel]);
const historyItems = renderModel.history;
const _liveHeadItems = renderModel.segments.liveHead;
const liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
@@ -686,27 +633,34 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
[lastHistoryItem, renderStreamItem],
);
const liveAuxiliaryHeaderStyle = useMemo(() => {
let headerPadding: { paddingBottom: number } | { paddingTop: number } | null;
if (!boundary.hasLiveHead) headerPadding = null;
else if (streamRenderStrategy.getFlatListInverted())
headerPadding = { paddingBottom: looseGap };
else headerPadding = { paddingTop: looseGap };
return [stylesheet.listHeaderContent, headerPadding];
}, [boundary.hasLiveHead, streamRenderStrategy, looseGap]);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
return null;
}
return (
<View style={stylesheet.contentWrapper}>
<View style={liveAuxiliaryHeaderStyle}>
<View
style={[
stylesheet.listHeaderContent,
boundary.hasLiveHead
? streamRenderStrategy.getFlatListInverted()
? { paddingBottom: looseGap }
: { paddingTop: looseGap }
: null,
]}
>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]);
}, [
auxiliary.pendingPermissions,
auxiliary.workingIndicator,
boundary.hasLiveHead,
looseGap,
streamRenderStrategy,
]);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
@@ -820,123 +774,18 @@ function WorkingIndicator() {
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
return (
<View style={stylesheet.workingIndicatorBubble}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
<Animated.View style={[stylesheet.workingDot, dotOneStyle]} />
<Animated.View style={[stylesheet.workingDot, dotTwoStyle]} />
<Animated.View style={[stylesheet.workingDot, dotThreeStyle]} />
</View>
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
>[0]["strategy"];
interface TurnCopyButtonSlotProps {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return <TurnCopyButton getContent={getContent} />;
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
> {
itemId: string;
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
}
function ToolCallSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
...rest
}: ToolCallSlotProps) {
const handleExpandedChange = useCallback(
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
[onInlineDetailsExpandedChangeByItemId, itemId],
);
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
interface PermissionActionButtonProps {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
textColor: string;
iconColor: string;
isDanger: boolean;
Icon: typeof Check;
testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void;
}
function PermissionActionButton({
action,
isRespondingAction,
isResponding,
textColor,
iconColor,
isDanger,
Icon,
testID,
theme,
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent, isDanger],
);
const optionTextStyle = useMemo(
() => [permissionStyles.optionText, { color: textColor }],
[textColor],
);
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={optionTextStyle}>{action.label}</Text>
</View>
)}
</Pressable>
);
}
function PermissionRequestCard({
permission,
client,
@@ -951,15 +800,6 @@ function PermissionRequestCard({
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const description = request.description ?? "";
const resolvedToolCallDetail = useMemo(
() =>
request.detail ?? {
type: "unknown" as const,
input: request.input ?? null,
output: null,
},
[request.detail, request.input],
);
const resolvedActions = useMemo((): AgentPermissionAction[] => {
if (request.kind === "question") {
return [];
@@ -1060,36 +900,6 @@ function PermissionRequestCard({
[handleResponse],
);
const questionTextStyle = useMemo(
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const optionsContainerStyle = useMemo(
() => [
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
],
[isMobile],
);
const cardContainerStyle = useMemo(
() => [
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[theme.colors.surface1, theme.colors.border],
);
const cardTitleStyle = useMemo(
() => [permissionStyles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cardDescriptionStyle = useMemo(
() => [permissionStyles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
if (request.kind === "question") {
return (
<QuestionFormCard
@@ -1102,11 +912,19 @@ function PermissionRequestCard({
const footer = (
<>
<Text testID="permission-request-question" style={questionTextStyle}>
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
>
How would you like to proceed?
</Text>
<View style={optionsContainerStyle}>
<View
style={[
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
]}
>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
@@ -1114,26 +932,39 @@ function PermissionRequestCard({
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement")
testID = "permission-request-accept";
else testID = `permission-request-action-${action.id}`;
const testID =
action.behavior === "deny"
? "permission-request-deny"
: action.id === "accept" || action.id === "implement"
? "permission-request-accept"
: `permission-request-action-${action.id}`;
return (
<PermissionActionButton
<Pressable
key={action.id}
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
textColor={textColor}
iconColor={iconColor}
isDanger={isDanger}
Icon={Icon}
testID={testID}
theme={theme}
onPress={handleActionPress}
/>
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => handleActionPress(action)}
disabled={isResponding}
>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={[permissionStyles.optionText, { color: textColor }]}>
{action.label}
</Text>
</View>
)}
</Pressable>
);
})}
</View>
@@ -1153,17 +984,38 @@ function PermissionRequestCard({
}
return (
<View style={cardContainerStyle}>
<Text style={cardTitleStyle}>{title}</Text>
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? <Text style={cardDescriptionStyle}>{description}</Text> : null}
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent detail={resolvedToolCallDetail} maxHeight={200} />
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
{footer}
@@ -1351,16 +1203,3 @@ const permissionStyles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
},
}));
interface StreamItemWrapperProps {
gapBelow: number;
children: ReactNode;
}
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
const wrapperStyle = useMemo(
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
[gapBelow],
);
return <View style={wrapperStyle}>{children}</View>;
}
@@ -54,16 +54,6 @@ function toolCall(id: string, seed: number): StreamItem {
};
}
function thought(id: string, seed: number): StreamItem {
return {
kind: "thought",
id,
text: id,
status: "ready",
timestamp: createTimestamp(seed),
};
}
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
return items.map((item, index) => ({ item, index }));
}
@@ -121,11 +111,6 @@ describe("splitWebVirtualizedHistory", () => {
});
describe("estimateStreamItemHeight", () => {
it("uses compact estimates for collapsed tool sequence rows", () => {
expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40);
expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40);
});
it("uses a larger estimate for user messages with image attachments", () => {
const item: StreamItem = {
kind: "user_message",
@@ -1,9 +1,8 @@
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
@@ -32,15 +31,15 @@ export function getWebMountedRecentStreamItems(): number {
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
export interface IndexedStreamItem {
export type IndexedStreamItem = {
item: StreamItem;
index: number;
}
};
export interface WebVirtualizedHistoryWindow {
export type WebVirtualizedHistoryWindow = {
virtualizedEntries: IndexedStreamItem[];
mountedEntries: IndexedStreamItem[];
}
};
export function estimateStreamItemHeight(item: StreamItem): number {
switch (item.kind) {
@@ -49,9 +48,9 @@ export function estimateStreamItemHeight(item: StreamItem): number {
case "assistant_message":
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
return 136;
case "thought":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
return 112;
case "todo_list":
return 144;
case "activity_log":
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated";
@@ -22,12 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
const containerStyle = useMemo(
() => [styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle],
[insets.bottom, keyboardAnimatedStyle],
);
const handleUnarchive = useCallback(async () => {
async function handleUnarchive() {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
try {
@@ -36,10 +31,12 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
}
return (
<Animated.View style={containerStyle}>
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.callout}>
@@ -59,7 +56,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
);
}
const styles = StyleSheet.create((theme: Theme) => ({
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
flexDirection: "column",
position: "relative",
@@ -99,4 +96,4 @@ const styles = StyleSheet.create((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
})) as unknown as Record<string, object>;
})) as any) as Record<string, any>;
@@ -0,0 +1,244 @@
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
export interface Artifact {
id: string;
type: "markdown" | "diff" | "image" | "code";
title: string;
content: string;
isBase64: boolean;
}
interface ArtifactDrawerProps {
artifact: Artifact | null;
onClose: () => void;
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
flexDirection: "column",
},
header: {
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
titleContainer: {
flex: 1,
marginRight: theme.spacing[4],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
badge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
badgeMarkdown: {
backgroundColor: theme.colors.primary,
},
badgeDiff: {
backgroundColor: theme.colors.palette.purple[600],
},
badgeImage: {
backgroundColor: theme.colors.palette.green[600],
},
badgeCode: {
backgroundColor: theme.colors.palette.orange[600],
},
badgeText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
closeButton: {
backgroundColor: theme.colors.surface2,
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
},
closeButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
contentScroll: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentScrollContainer: {
padding: theme.spacing[4],
flexGrow: 1,
},
imagePlaceholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
imagePlaceholderText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
imagePlaceholderSubtext: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[2],
},
codeContainer: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
codeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: Fonts.mono,
},
metadataContainer: {
backgroundColor: theme.colors.surface2,
padding: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
marginTop: theme.spacing[2],
},
metadataTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[2],
},
metadataRow: {
flexDirection: "row",
marginBottom: theme.spacing[1],
},
metadataLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
width: 80,
},
metadataValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flex: 1,
fontFamily: Fonts.mono,
},
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
const webScrollbarStyle = useWebScrollbarStyle();
if (!artifact) {
return null;
}
// Decode content if base64
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
// Type badge style mapping
const typeBadgeStyles = {
markdown: styles.badgeMarkdown,
diff: styles.badgeDiff,
image: styles.badgeImage,
code: styles.badgeCode,
};
return (
<Modal
visible={!!artifact}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView edges={["top", "bottom"]} style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerRow}>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{artifact.title}
</Text>
</View>
<View style={styles.headerActions}>
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
</Pressable>
</View>
</View>
</View>
{/* Content */}
<ScrollView
style={[styles.contentScroll, webScrollbarStyle]}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
)}
</ScrollView>
{/* Metadata - Fixed at bottom */}
<View style={styles.metadataContainer}>
<Text style={styles.metadataTitle}>METADATA</Text>
<View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>ID:</Text>
<Text style={styles.metadataValue}>{artifact.id}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Type:</Text>
<Text style={styles.metadataValue}>{artifact.type}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Encoding:</Text>
<Text style={styles.metadataValue}>
{artifact.isBase64 ? "Base64" : "Plain text"}
</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
</View>
</View>
</View>
</SafeAreaView>
</Modal>
);
}
@@ -7,7 +7,7 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { AttachmentLightbox } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
const hoistedTheme = {
const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
@@ -24,7 +24,7 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
},
};
const hoistedImageMetadata: AttachmentMetadata = {
const imageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
@@ -35,8 +35,8 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
};
return {
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
theme,
imageMetadata,
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
@@ -82,7 +82,13 @@ vi.mock("expo-image", () => ({
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
const Modal = ({ visible = true, children }: { visible?: boolean; children?: React.ReactNode }) =>
const Modal = ({
visible = true,
children,
}: {
visible?: boolean;
children?: React.ReactNode;
}) =>
visible ? React.createElement("div", { "data-testid": "lightbox-modal" }, children) : null;
return { ...actual, Modal };
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -36,21 +36,6 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
};
}, [metadata, onClose]);
const closeButtonStyle = useMemo(
() => [
styles.closeButton,
{
top: insets.top + theme.spacing[3],
right: insets.right + theme.spacing[3],
},
],
[insets.top, insets.right, theme.spacing],
);
const handleImageError = useCallback(() => setErrored(true), []);
const noopPress = useCallback(() => {}, []);
const imageSource = useMemo(() => ({ uri: url ?? "" }), [url]);
if (!metadata) {
return null;
}
@@ -70,14 +55,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>Couldn&apos;t load image</Text>
<Text style={styles.errorText}>Couldn't load image</Text>
) : (
<Pressable onPress={noopPress} style={styles.imagePressable}>
<Pressable onPress={() => {}} style={styles.imagePressable}>
<ExpoImage
testID="attachment-lightbox-image"
source={imageSource}
source={{ uri: url }}
contentFit="contain"
onError={handleImageError}
onError={() => setErrored(true)}
style={imageFillStyle}
/>
</Pressable>
@@ -89,7 +74,13 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
accessibilityLabel="Close image"
hitSlop={8}
onPress={onClose}
style={closeButtonStyle}
style={[
styles.closeButton,
{
top: insets.top + theme.spacing[3],
right: insets.right + theme.spacing[3],
},
]}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useMemo, useState } from "react";
import { type ReactNode, useState } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -30,22 +30,14 @@ export function AttachmentPill({
const [isCloseHovered, setIsCloseHovered] = useState(false);
const alwaysShow = isNative || isCompact;
const showRemove = alwaysShow || isBodyHovered || isCloseHovered;
const closeButtonStyle = useMemo(
() => [styles.closeButton, !showRemove && styles.closeButtonHidden],
[showRemove],
);
const handleBodyHoverIn = useCallback(() => setIsBodyHovered(true), []);
const handleBodyHoverOut = useCallback(() => setIsBodyHovered(false), []);
const handleCloseHoverIn = useCallback(() => setIsCloseHovered(true), []);
const handleCloseHoverOut = useCallback(() => setIsCloseHovered(false), []);
return (
<View style={styles.wrapper}>
<Pressable
testID={testID}
onPress={onOpen}
disabled={disabled}
onHoverIn={handleBodyHoverIn}
onHoverOut={handleBodyHoverOut}
onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)}
accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel}
style={styles.body}
@@ -55,12 +47,12 @@ export function AttachmentPill({
<Pressable
onPress={onRemove}
disabled={disabled}
onHoverIn={handleCloseHoverIn}
onHoverOut={handleCloseHoverOut}
onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={removeAccessibilityLabel}
style={closeButtonStyle}
style={[styles.closeButton, !showRemove && styles.closeButtonHidden]}
>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -0,0 +1,190 @@
import { useState, useMemo } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import * as Clipboard from "expo-clipboard";
import { Check, Copy, X } from "lucide-react-native";
export interface AudioDebugInfo {
requestId?: string | null;
transcript?: string;
debugRecordingPath?: string;
format?: string;
byteLength?: number;
duration?: number;
avgLogprob?: number;
isLowConfidence?: boolean;
}
interface AudioDebugNoticeProps {
info: AudioDebugInfo | null;
onDismiss?: () => void;
title?: string;
}
function formatBytes(bytes?: number): string | null {
if (!bytes || bytes <= 0) {
return null;
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ["KB", "MB", "GB"] as const;
let value = bytes;
let unitIndex = -1;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatDuration(duration?: number): string | null {
if (!duration || duration <= 0) {
return null;
}
const seconds = duration / 1000;
if (seconds < 1) {
return `${(seconds * 1000).toFixed(0)} ms`;
}
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
}
export function AudioDebugNotice({
info,
onDismiss,
title = "Dictation Debug",
}: AudioDebugNoticeProps) {
const { theme } = useUnistyles();
const [copied, setCopied] = useState(false);
const stats = useMemo(() => {
if (!info) {
return null;
}
const parts: string[] = [];
if (info.format) {
parts.push(info.format);
}
const size = formatBytes(info.byteLength);
if (size) {
parts.push(size);
}
const duration = formatDuration(info.duration);
if (duration) {
parts.push(duration);
}
if (info.avgLogprob !== undefined) {
const label = `${info.avgLogprob.toFixed(2)} avg logprob`;
parts.push(info.isLowConfidence ? `${label} (low confidence)` : label);
}
return parts.join(" · ");
}, [info]);
if (!info) {
return null;
}
const handleCopyPath = async () => {
if (!info.debugRecordingPath) {
return;
}
try {
await Clipboard.setStringAsync(info.debugRecordingPath);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.warn("[AudioDebug] Failed to copy path", error);
}
};
const pathMissing = !info.debugRecordingPath;
return (
<View
style={[
styles.container,
{
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.border,
},
]}
>
<View style={styles.headerRow}>
<Text style={[styles.title, { color: theme.colors.foregroundMuted }]}>{title}</Text>
{onDismiss ? (
<Pressable accessibilityLabel="Dismiss audio debug" onPress={onDismiss} hitSlop={8}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</View>
{info.debugRecordingPath ? (
<Pressable
style={styles.pathRow}
onPress={handleCopyPath}
accessibilityLabel="Copy raw audio path"
>
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
{info.debugRecordingPath}
</Text>
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
{copied ? (
<Check size={12} color={theme.colors.primaryForeground} />
) : (
<Copy size={12} color={theme.colors.primaryForeground} />
)}
</View>
</Pressable>
) : (
<Text style={[styles.hint, { color: theme.colors.foregroundMuted }]}>
Raw audio path unavailable. Set STT_DEBUG_AUDIO_DIR on the server to persist recordings.
</Text>
)}
{stats ? (
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
padding: 12,
gap: 8,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
title: {
fontSize: 12,
fontWeight: "600",
textTransform: "uppercase",
},
pathRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
pathText: {
flex: 1,
fontSize: 13,
},
copyPill: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
},
stats: {
fontSize: 12,
},
hint: {
fontSize: 12,
},
});

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