Compare commits

...
Author SHA1 Message Date
Mohamed Boudra aa1dc3ac21 chore(release): cut 0.1.29 2026-03-18 14:33:44 +07:00
Mohamed Boudra 00659bc32d docs(changelog): note 0.1.29 hotfix 2026-03-18 14:33:35 +07:00
Mohamed Boudra 6c11a082c5 Fix agent spawn PATH resolution and add provider binary checks to status
Shell env PATH was being overridden by process.env PATH in
applyProviderEnv, causing agent spawns to fail with ENOENT when the
daemon runs from the Tauri desktop app (minimal GUI PATH). Flip the
merge order so login shell env wins.

Add a Providers section to `paseo daemon status` that resolves each
agent binary (claude, codex, opencode) and runs --version using the
same applyProviderEnv environment the daemon uses to spawn agents.
2026-03-18 14:31:16 +07:00
Mohamed Boudra 745f2e2768 chore(release): cut 0.1.28 2026-03-15 13:53:47 +07:00
Mohamed Boudra ef55fecfe2 docs(changelog): prepare 0.1.28 release notes 2026-03-15 13:53:03 +07:00
Mohamed Boudra cc675da49c Stabilize git action menu 2026-03-15 13:51:04 +07:00
Mohamed Boudra a9d5502fad fix(app): route assistant file links to workspace files 2026-03-15 13:50:52 +07:00
Mohamed Boudra 849f49b622 fix: settings screen design tweaks for mobile
- Center settings cog icon vertically in host card
- Normalize icon sizes (relay/host/settings all use iconSize.sm)
- Hide theme picker labels on mobile (icon-only)
- Simplify audio test copy, remove verbose description
2026-03-15 13:50:50 +07:00
Mohamed Boudra d89f1ea663 fix: align mobile workspace header icons with desktop
Use same explorer toggle icons (SourceControlPanelIcon/PanelRight) on
mobile as desktop, use vertical kebab icon for mobile header menu.
2026-03-15 13:50:42 +07:00
Mohamed Boudra 5701543746 refactor: move startup logic to app-support, improve welcome screen UI 2026-03-15 12:52:29 +07:00
Mohamed Boudra 57db31c662 feat(website): update hero copy, add sponsor CTA section
Update hero title to "All your coding agents, from anywhere" with
responsive line break. Add sponsor CTA section with personal messaging.
Update meta title for SEO. Fix GitHub sponsors link.
2026-03-15 12:07:14 +07:00
Mohamed Boudra eadca4a96b Remove fix-tests script 2026-03-15 11:27:23 +07:00
Mohamed Boudra 4a111099c4 docs: fix license to AGPL-3.0 in README 2026-03-15 11:25:35 +07:00
Mohamed Boudra 6f34699e4b Add OpenCode build and plan modes 2026-03-15 11:17:23 +07:00
Mohamed Boudra aa6c521394 feat(website): add SEO landing pages for Claude Code, Codex, and OpenCode
Extract homepage into reusable LandingPage component with configurable
hero. Add /claude-code, /codex, and /opencode routes targeting
agent-specific mobile app search terms. Replace single-link footer
with four-column layout (Product, Agents, Community, Download).
2026-03-15 11:16:28 +07:00
49 changed files with 2626 additions and 1928 deletions
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## 0.1.29 - 2026-03-18
### Improved
- Improved `paseo daemon status` with provider binary resolution and version checks for Claude, Codex, and OpenCode.
### Fixed
- Fixed desktop-managed agent startup failures caused by the daemon using the GUI process PATH instead of the user's login shell PATH.
## 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
+1 -1
View File
@@ -65,4 +65,4 @@ npm run typecheck
## License
MIT
AGPL-3.0
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MAX_ITERATIONS="${MAX_ITERATIONS:-20}"
SLEEP_BETWEEN="${SLEEP_BETWEEN:-5}"
GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m'
log() { echo -e "${GREEN}[fix-tests]${NC} $*"; }
warn() { echo -e "${YELLOW}[fix-tests]${NC} $*"; }
if [ "$(git branch --show-current)" = "main" ]; then
BRANCH="fix-tests-$(date +%s)"
log "Creating branch $BRANCH"
git checkout -b "$BRANCH"
fi
read -r -d '' AGENT_PROMPT << 'PROMPT' || true
Find ONE failing test across the entire repo and fix it.
The objective: get CI green on GitHub Actions. The build has been red for ages and it's embarrassing. Not all tests can run in GH Actions but MOST of them should.
Use --bail 1 / --max-failures 1 / fail-fast ALWAYS. Do not run the whole suite. Find the first failure FAST.
Auth is fully configured: CLAUDE_CODE_OAUTH_TOKEN and OPENAI_API_KEY are set. OpenCode uses free models needing no auth. All three providers work. There is zero reason to check for auth or skip tests based on environment.
Work across all packages — server unit tests, server e2e, daemon tests, app unit tests, Playwright browser e2e, CLI integration tests. All of them.
When a test fails:
- Outdated (tests removed/renamed APIs) → fix or delete if unnecessary
- Flaky (races, timing) → make it deterministic
- Too slow → make it fast or delete
Do NOT weaken tests. Do NOT add conditionals. Do NOT introduce mocks — we use real environment on purpose because we want to test the REAL thing. Do NOT add auth gating or skip conditions. Do NOT implement new features to make a test pass. You are here to fix tests, not build features. If a test expects unimplemented behavior, delete it.
While you're in a file, improve it:
- Refactor repeated code RUTHLESSLY. Extract shared helpers across test files.
- Simplify complex tests. Tests should read almost like plain English.
Test philosophy for this project:
- What we love: general e2e tests that test as close to the user as possible. CLI tests against a real daemon. E2E tests against a real daemon. Playwright browser tests. These are the most valuable.
- Unit tests are for pure functions and specific provider functionality.
- If a test doesn't add real value, delete it. Don't preserve tests for coverage theater.
Resource hygiene:
- Many tests spawn temporary daemons or do heavy setup. Pay attention to cleanup.
- Make sure cleanup works consistently and runs even on failure.
- When killing processes, ensure you're ONLY killing test processes that YOU ran. Use PIDs, not broad patterns.
After changes: run typecheck (npm run typecheck --workspaces --if-present).
Skip *.real.e2e.test.ts and *.local.e2e.test.ts (local-only manual testing).
PROMPT
for i in $(seq 1 "$MAX_ITERATIONS"); do
log "━━━ Iteration $i/$MAX_ITERATIONS ━━━"
if ! git diff --quiet 2>/dev/null; then
git add -A
git commit -m "fix(tests): iteration $((i-1))" || true
fi
log "Launching Codex agent via Paseo..."
paseo run \
--provider codex \
--model gpt-5.4 \
--thinking medium \
--mode full-access \
--name "Fix Tests #$i" \
"$AGENT_PROMPT" || {
warn "Agent exited non-zero (iteration $i), continuing..."
}
if ! git diff --quiet 2>/dev/null || [ -n "$(git ls-files --others --exclude-standard)" ]; then
git add -A
git commit -m "fix(tests): iteration $i" || true
else
warn "No changes in iteration $i"
fi
sleep "$SLEEP_BETWEEN"
done
log "Done. Review: git log --oneline main..HEAD"
+30 -61
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.27",
"version": "0.1.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.27",
"version": "0.1.29",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -8881,7 +8881,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8895,7 +8894,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8909,7 +8907,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8923,7 +8920,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8937,7 +8933,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8951,7 +8946,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8965,7 +8959,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8979,7 +8972,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8993,7 +8985,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9007,7 +8998,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9021,7 +9011,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9035,7 +9024,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9049,7 +9037,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9063,7 +9050,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9077,7 +9063,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9091,7 +9076,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9105,7 +9089,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9119,7 +9102,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9133,7 +9115,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9147,7 +9128,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9161,7 +9141,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9175,7 +9154,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9189,7 +9167,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9203,7 +9180,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9217,7 +9193,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -10747,7 +10722,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -10757,7 +10731,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -13913,7 +13887,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -15262,6 +15235,7 @@
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -19791,7 +19765,7 @@
"version": "16.8.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
@@ -21708,7 +21682,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -26207,6 +26181,18 @@
}
}
},
"node_modules/react-dom": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.4"
}
},
"node_modules/react-error-boundary": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
@@ -26224,18 +26210,6 @@
"react": ">=16.13.1"
}
},
"node_modules/react-dom": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.4"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
@@ -26774,7 +26748,6 @@
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -27414,7 +27387,6 @@
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -30467,7 +30439,6 @@
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
@@ -30585,7 +30556,6 @@
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -31399,7 +31369,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -31481,15 +31450,15 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.27",
"version": "0.1.29",
"dependencies": {
"@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": "file:../expo-two-way-audio",
"@getpaseo/server": "0.1.27",
"@getpaseo/expo-two-way-audio": "0.1.29",
"@getpaseo/server": "0.1.29",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -31607,11 +31576,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.27",
"version": "0.1.29",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.27",
"@getpaseo/server": "0.1.27",
"@getpaseo/relay": "0.1.29",
"@getpaseo/server": "0.1.29",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -31648,14 +31617,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.27",
"version": "0.1.29",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.4",
"version": "0.1.29",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -31692,7 +31661,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.27",
"version": "0.1.29",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -31708,12 +31677,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.27",
"version": "0.1.29",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.27",
"@getpaseo/relay": "0.1.29",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -32070,7 +32039,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.27",
"version": "0.1.29",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.27",
"version": "0.1.29",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.27",
"version": "0.1.29",
"private": true,
"scripts": {
"start": "expo start",
@@ -26,13 +26,13 @@
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
"@getpaseo/expo-two-way-audio": "file:../expo-two-way-audio",
"@getpaseo/expo-two-way-audio": "0.1.29",
"@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/server": "0.1.27",
"@getpaseo/server": "0.1.29",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
+3 -1
View File
@@ -61,6 +61,7 @@ import {
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
@@ -469,7 +470,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
useFaviconStatus();
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
+1 -1
View File
@@ -10,7 +10,7 @@ import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from './index-startup'
} from '@/app-support/index-startup'
const STARTUP_TIMEOUT_MS = 30_000
function useAnyHostOnline(serverIds: string[]): string | null {
@@ -69,6 +69,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -155,7 +156,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return;
}
const normalized = normalizeInlinePath(target.path, agent.cwd);
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
if (!normalized) {
return;
}
@@ -314,6 +315,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
/>
);
@@ -656,102 +658,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
);
});
function normalizeInlinePath(
rawPath: string,
cwd?: string
): { directory: string; file?: string } | null {
if (!rawPath) {
return null;
}
const normalizedInput = normalizePathInput(rawPath);
if (!normalizedInput) {
return null;
}
let normalized = normalizedInput;
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
if (cwdRelative) {
normalized = cwdRelative;
}
if (normalized.startsWith("./")) {
normalized = normalized.slice(2) || ".";
}
if (!normalized.length) {
normalized = ".";
}
if (normalized === ".") {
return { directory: "." };
}
if (normalized.endsWith("/")) {
const dir = normalized.replace(/\/+$/, "");
return { directory: dir.length > 0 ? dir : "." };
}
const lastSlash = normalized.lastIndexOf("/");
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
return {
directory: directory.length > 0 ? directory : ".",
file: normalized,
};
}
function normalizePathInput(value: string | undefined): string | null {
if (!value) {
return null;
}
const trimmed = value
.trim()
.replace(/^['"`]/, "")
.replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
}
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
const normalizedCwd = normalizePathInput(cwd);
if (
!normalizedCwd ||
!isAbsolutePath(pathValue) ||
!isAbsolutePath(normalizedCwd)
) {
return null;
}
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
const comparePath = normalizePathForCompare(pathValue);
const compareCwd = normalizePathForCompare(normalizedCwdBase);
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
const comparePrefix = normalizePathForCompare(prefix);
if (comparePath === compareCwd) {
return ".";
}
if (comparePath.startsWith(comparePrefix)) {
return pathValue.slice(prefix.length) || ".";
}
return null;
}
function normalizePathForCompare(value: string): string {
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
}
function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
}
function WorkingIndicator() {
const dotOne = useSharedValue(0);
const dotTwo = useSharedValue(0);
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
describe("git-action-visibility", () => {
describe("shouldShowMergeFromBaseAction", () => {
it("shows on non-base branches", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: false,
hasRemote: false,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(true);
});
it("hides on base branch when no remote exists", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: false,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(false);
});
it("hides on base branch when local is in sync with origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(false);
});
it("shows on base branch when ahead of origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 1,
behindOfOrigin: 0,
})
).toBe(true);
});
it("shows on base branch when behind origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 2,
})
).toBe(true);
});
});
});
@@ -1,14 +0,0 @@
export function shouldShowMergeFromBaseAction(input: {
isOnBaseBranch: boolean;
hasRemote: boolean;
aheadOfOrigin: number;
behindOfOrigin: number;
}): boolean {
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
}
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
shipDefault: "merge",
runtime: {
commit: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-from-base": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"archive-worktree": {
disabled: false,
status: "idle",
handler: () => undefined,
},
},
...overrides,
};
}
describe("git-actions-policy", () => {
it("keeps the secondary menu order stable while the primary action changes", () => {
const noPrActions = buildGitActions(createInput());
const withPrActions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/123",
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
})
);
expect(noPrActions.primary).toBeNull();
expect(withPrActions.primary?.id).toBe("push");
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
});
it("disables hidden-before actions with explanations instead", () => {
const actions = buildGitActions(createInput());
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
expect(actionById.get("push")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.get("pr")).toMatchObject({
label: "Create PR",
disabled: true,
description: "Branch has no commits ahead of main",
});
expect(actionById.get("merge-branch")).toMatchObject({
disabled: true,
description: "No commits to merge into main",
});
expect(actionById.get("merge-from-base")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.has("archive-worktree")).toBe(false);
});
it("keeps the current primary action visible in the menu", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
})
);
expect(actions.primary?.id).toBe("pr");
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
});
it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
})
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(syncAction).toMatchObject({
label: "Sync",
disabled: true,
description: "Already up to date",
});
});
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
});
@@ -0,0 +1,256 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "push"
| "pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
export interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
icon?: ReactElement;
handler: () => void;
}
export interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
interface GitActionRuntimeState {
disabled: boolean;
status: ActionStatus;
icon?: ReactElement;
handler: () => void;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
aheadCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
shipDefault: "merge" | "pr";
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = [
"merge-branch",
"pr",
"merge-from-base",
"push",
];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
return { primary: null, secondary: [], menu: [] };
}
const allActions = new Map<GitActionId, GitAction>();
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
handler: input.runtime.commit.handler,
});
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled || !input.hasRemote,
status: input.runtime.push.status,
description: input.hasRemote ? undefined : "No remote configured",
icon: input.runtime.push.icon,
handler: input.runtime.push.handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled:
input.runtime["merge-branch"].disabled ||
!input.baseRefAvailable ||
input.hasUncommittedChanges ||
input.aheadCount === 0,
status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
allActions.set("merge-from-base", {
id: "merge-from-base",
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
status: input.runtime["merge-from-base"].status,
description: getMergeFromBaseDescription(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
status: input.runtime["archive-worktree"].status,
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!);
}
return { primary, secondary, menu: [] };
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
}
if (input.hasUncommittedChanges) {
return "commit";
}
if (input.aheadOfOrigin > 0 && input.hasRemote) {
return "push";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (
input.isOnBaseBranch &&
input.hasRemote &&
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
) {
return "merge-from-base";
}
if (input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
}
return null;
}
function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) {
return {
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
status: input.runtime.pr.status,
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
return {
id: "pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled:
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
return false;
}
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
}
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "GitHub features unavailable";
}
if (input.aheadCount === 0) {
return `Branch has no commits ahead of ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
}
if (input.aheadCount === 0) {
return `No commits to merge into ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
}
if (!input.isOnBaseBranch) {
return undefined;
}
if (!input.hasRemote) {
return "No remote configured";
}
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
return "Already up to date";
}
return undefined;
}
@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -79,7 +79,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
closeOnSelect={
action.status === "idle" &&
action.id === "pr" &&
action.label === "View PR"
}
description={action.description}
onSelect={action.handler}
>
+69 -192
View File
@@ -47,19 +47,19 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
buildGitActions,
type GitActions,
} from "@/components/git-actions-policy";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
// Re-export types from shared hook
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
@@ -790,19 +790,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled =
actionsDisabled ||
mergeFromBaseStatus === "pending" ||
hasUncommittedChanges ||
!baseRef ||
(isOnBaseBranch && !hasRemote);
actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled =
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
actionsDisabled || pushStatus === "pending";
const archiveDisabled =
actionsDisabled ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
actionsDisabled || archiveStatus === "pending";
let bodyContent: ReactElement;
@@ -892,184 +886,67 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
// ==========================================================================
const gitActions: GitActions = useMemo(() => {
if (!isGit) {
return { primary: null, secondary: [], menu: [] };
}
// Build all possible actions
const allActions = new Map<GitActionId, GitAction>();
// Commit - always available
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitStatus,
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
return buildGitActions({
isGit,
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
runtime: {
commit: {
disabled: commitDisabled,
status: commitStatus,
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
},
push: {
disabled: pushDisabled,
status: pushStatus,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
},
pr: {
disabled: prDisabled,
status: hasPullRequest ? "idle" : prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
},
},
"merge-branch": {
disabled: mergeDisabled,
status: mergeStatus,
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: archiveDisabled,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: handleArchiveWorktree,
},
},
});
// Push - when has remote
if (hasRemote) {
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
});
}
// View PR - when PR exists
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
status: "idle",
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => openURLInNewTab(prUrl),
});
}
// Create PR - when ahead of base and no PR
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: handleCreatePr,
});
}
// Merge branch - when ahead of base
if (aheadCount > 0) {
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeBranch,
});
}
// Update/sync from base
if (
shouldShowMergeFromBaseAction({
isOnBaseBranch,
hasRemote,
aheadOfOrigin,
behindOfOrigin,
})
) {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
description:
hasUncommittedChanges
? "Requires clean working tree"
: isOnBaseBranch && !hasRemote
? "No remote configured"
: undefined,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
});
}
// Archive worktree - only for Paseo worktrees
if (isPaseoOwnedWorktree) {
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: handleArchiveWorktree,
});
}
// Select primary action (priority rules)
let primaryActionId: GitActionId | null = null;
// Rule 0: Post-ship in worktree -> Archive
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
primaryActionId = "archive-worktree";
}
// Rule 1: Uncommitted changes → Commit
else if (hasUncommittedChanges) {
primaryActionId = "commit";
}
// Rule 2: Ahead of origin → Push
else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
}
// Rule 3: Has PR → View PR
else if (hasPullRequest) {
primaryActionId = "view-pr";
}
// Rule 4: On base branch -> surface sync explicitly
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
primaryActionId = "merge-from-base";
}
// Rule 5: Ahead of base → Ship action based on preference
else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
const preferredAction = allActions.get(preferred);
const fallbackAction = allActions.get(fallback);
if (preferredAction && !preferredAction.disabled) {
primaryActionId = preferred;
} else if (fallbackAction && !fallbackAction.disabled) {
primaryActionId = fallback;
} else if (preferredAction) {
primaryActionId = preferred;
}
}
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
// Secondary actions: ship-related + merge from base + push (excluding primary)
const secondaryIds: GitActionId[] = [
"merge-branch",
"create-pr",
"view-pr",
"merge-from-base",
"push",
"archive-worktree",
];
const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!);
// Menu actions: none for now (all actionable items are in primary/secondary)
const menu: GitAction[] = [];
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
@@ -3,11 +3,13 @@ import Svg, { Rect, Line } from "react-native-svg";
interface SourceControlPanelIconProps {
size?: number;
color?: string;
strokeWidth?: number;
}
export function SourceControlPanelIcon({
size = 16,
color = "currentColor",
strokeWidth = 2,
}: SourceControlPanelIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
@@ -18,15 +20,15 @@ export function SourceControlPanelIcon({
height={18}
rx={2}
stroke={color}
strokeWidth={2}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Plus */}
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
{/* Minus */}
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
</Svg>
);
}
+5 -3
View File
@@ -72,7 +72,7 @@ import {
} from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import {
parseFileProtocolUrl,
parseAssistantFileLink,
parseInlinePathToken,
type InlinePathTarget,
} from "@/utils/inline-path";
@@ -429,6 +429,7 @@ interface AssistantMessageProps {
message: string;
timestamp: number;
onInlinePathPress?: (target: InlinePathTarget) => void;
workspaceRoot?: string;
disableOuterSpacing?: boolean;
}
@@ -744,6 +745,7 @@ export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
onInlinePathPress,
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
const { theme } = useUnistyles();
@@ -770,7 +772,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const handleLinkPress = useCallback((url: string) => {
const fileTarget = onInlinePathPress
? parseFileProtocolUrl(url)
? parseAssistantFileLink(url, { workspaceRoot })
: null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
@@ -781,7 +783,7 @@ export const AssistantMessage = memo(function AssistantMessage({
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}, [onInlinePathPress]);
}, [onInlinePathPress, workspaceRoot]);
const markdownRules = useMemo(() => {
return {
@@ -23,6 +23,7 @@ type SegmentedControlProps<T extends string> = {
value: T;
onValueChange: (value: T) => void;
size?: SegmentedControlSize;
hideLabels?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
};
@@ -32,6 +33,7 @@ export function SegmentedControl<T extends string>({
value,
onValueChange,
size = "md",
hideLabels = false,
style,
testID,
}: SegmentedControlProps<T>) {
@@ -72,12 +74,14 @@ export function SegmentedControl<T extends string>({
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
<Text
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
numberOfLines={1}
>
{option.label}
</Text>
{hideLabels ? null : (
<Text
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
numberOfLines={1}
>
{option.label}
</Text>
)}
</Pressable>
);
})}
+85 -41
View File
@@ -1,8 +1,9 @@
import { useCallback, useState } from "react";
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -12,12 +13,23 @@ import { NameHostModal } from "./name-host-modal";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
label: string;
testID: string;
primary: boolean;
icon: typeof QrCode;
onPress: () => void;
};
const styles = StyleSheet.create((theme) => ({
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[6],
paddingBottom: 0,
alignItems: "center",
},
content: {
@@ -26,11 +38,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
alignItems: "center",
},
logo: {
width: 96,
height: 96,
marginBottom: theme.spacing[6],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xl,
@@ -61,8 +68,8 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
},
actionButtonPrimary: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
actionText: {
color: theme.colors.foreground,
@@ -70,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
},
actionTextPrimary: {
color: theme.colors.palette.white,
color: theme.colors.accentForeground,
},
versionLabel: {
color: theme.colors.foregroundMuted,
@@ -86,6 +93,7 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const { renameHost } = useHostMutations();
const appVersion = resolveAppVersion();
@@ -111,51 +119,87 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router]
);
const actions: WelcomeAction[] = Platform.OS === "web"
? [
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
]
: [
{
key: "scan-qr",
label: "Scan QR code",
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
},
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
];
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
contentContainerStyle={styles.container}
contentContainerStyle={[
styles.container,
{ paddingBottom: theme.spacing[6] + insets.bottom },
]}
showsVerticalScrollIndicator={false}
testID="welcome-screen"
>
<View style={styles.content}>
<Image
source={require("../../assets/images/icon.png")}
style={styles.logo}
resizeMode="contain"
/>
<PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Add a host to start.</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text>
<View style={styles.actions}>
<Pressable
style={[styles.actionButton, styles.actionButtonPrimary]}
onPress={() => setIsDirectOpen(true)}
testID="welcome-direct-connection"
>
<Link2 size={18} color={theme.colors.palette.white} />
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
</Pressable>
<Pressable
style={styles.actionButton}
onPress={() => setIsPasteLinkOpen(true)}
testID="welcome-paste-pairing-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<Text style={styles.actionText}>Paste pairing link</Text>
</Pressable>
{Platform.OS !== "web" ? (
{actions.map((action) => {
const Icon = action.icon;
return (
<Pressable
style={styles.actionButton}
onPress={() => router.push("/pair-scan?source=onboarding")}
testID="welcome-scan-qr"
key={action.key}
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
onPress={action.onPress}
testID={action.testID}
>
<QrCode size={18} color={theme.colors.foreground} />
<Text style={styles.actionText}>Scan QR code</Text>
<Icon
size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
{action.label}
</Text>
</Pressable>
) : null}
);
})}
</View>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
@@ -554,7 +554,7 @@ function PairingOfferDialogContent(input: {
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: 'M',
margin: 1,
width: 320,
width: 480,
})
.then((dataUrl) => {
if (cancelled) {
@@ -606,17 +606,19 @@ function PairingOfferDialogContent(input: {
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
<View style={styles.linkSection}>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
</View>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onCopyLink}>
Copy link
@@ -717,18 +719,21 @@ const styles = StyleSheet.create((theme) => ({
qrCard: {
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
minHeight: 220,
minWidth: 220,
padding: theme.spacing[4],
width: '100%',
aspectRatio: 1,
alignSelf: 'stretch',
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
qrImage: {
width: 220,
height: 220,
width: '100%',
height: '100%',
},
linkSection: {
gap: theme.spacing[2],
},
linkLabel: {
color: theme.colors.foreground,
+69 -194
View File
@@ -4,37 +4,14 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { shouldShowMergeFromBaseAction } from "@/components/git-action-visibility";
import {
buildGitActions,
type GitActions,
} from "@/components/git-actions-policy";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "push"
| "view-pr"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
export interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
icon?: ReactElement;
handler: () => void;
}
export interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
@@ -252,19 +229,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled =
actionsDisabled ||
mergeFromBaseStatus === "pending" ||
hasUncommittedChanges ||
!baseRef ||
(isOnBaseBranch && !hasRemote);
actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled =
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
actionsDisabled || pushStatus === "pending";
const archiveDisabled =
actionsDisabled ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
actionsDisabled || archiveStatus === "pending";
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
@@ -275,163 +246,67 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
// Build actions
const gitActions: GitActions = useMemo(() => {
if (!isGit) {
return { primary: null, secondary: [], menu: [] };
}
const allActions = new Map<GitActionId, GitAction>();
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitStatus,
icon: icons.commit,
handler: handleCommit,
return buildGitActions({
isGit,
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
runtime: {
commit: {
disabled: commitDisabled,
status: commitStatus,
icon: icons.commit,
handler: handleCommit,
},
push: {
disabled: pushDisabled,
status: pushStatus,
icon: icons.push,
handler: handlePush,
},
pr: {
disabled: prDisabled,
status: hasPullRequest ? "idle" : prCreateStatus,
icon: hasPullRequest ? icons.viewPr : icons.createPr,
handler: () => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
},
},
"merge-branch": {
disabled: mergeDisabled,
status: mergeStatus,
icon: icons.merge,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: archiveDisabled,
status: archiveStatus,
icon: icons.archive,
handler: handleArchiveWorktree,
},
},
});
if (hasRemote) {
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
icon: icons.push,
handler: handlePush,
});
}
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
status: "idle",
icon: icons.viewPr,
handler: () => openURLInNewTab(prUrl),
});
}
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateStatus,
icon: icons.createPr,
handler: handleCreatePr,
});
}
if (aheadCount > 0) {
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
icon: icons.merge,
handler: handleMergeBranch,
});
}
if (
shouldShowMergeFromBaseAction({
isOnBaseBranch,
hasRemote,
aheadOfOrigin,
behindOfOrigin,
})
) {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
description:
hasUncommittedChanges
? "Requires clean working tree"
: isOnBaseBranch && !hasRemote
? "No remote configured"
: undefined,
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
});
}
if (isPaseoOwnedWorktree) {
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveStatus,
icon: icons.archive,
handler: handleArchiveWorktree,
});
}
// Select primary action (priority rules)
let primaryActionId: GitActionId | null = null;
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
primaryActionId = "archive-worktree";
} else if (hasUncommittedChanges) {
primaryActionId = "commit";
} else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
} else if (hasPullRequest) {
primaryActionId = "view-pr";
} else if (isOnBaseBranch && allActions.has("merge-from-base")) {
primaryActionId = "merge-from-base";
} else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
const preferredAction = allActions.get(preferred);
const fallbackAction = allActions.get(fallback);
if (preferredAction && !preferredAction.disabled) {
primaryActionId = preferred;
} else if (fallbackAction && !fallbackAction.disabled) {
primaryActionId = fallback;
} else if (preferredAction) {
primaryActionId = preferred;
}
}
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
const secondaryIds: GitActionId[] = [
"merge-branch",
"create-pr",
"view-pr",
"merge-from-base",
"push",
"archive-worktree",
];
const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!);
const menu: GitAction[] = [];
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
@@ -7,6 +7,7 @@ import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
const EMPTY_ORDER: string[] = []
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {}
export type SidebarStateBucket = WorkspaceDescriptor['status']
@@ -271,7 +272,7 @@ export function useSidebarWorkspacesList(options?: {
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
)
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
serverId ? state.workspaceOrderByServerAndProject : {}
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE
)
const isActive = Boolean(serverId)
+10 -10
View File
@@ -78,16 +78,16 @@ function formatActiveConnectionBadge(input: {
return null;
}
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
return { icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
if (activeConnection.type === "directSocket") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
return { icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />, text: "Local" };
}
if (activeConnection.type === "directPipe") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
return { icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />, text: "Local" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
}
@@ -230,6 +230,8 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
gap: 0,
marginLeft: theme.spacing[2],
alignItems: "center",
justifyContent: "center",
},
advancedTrigger: {
flexDirection: "row",
@@ -685,7 +687,7 @@ export default function SettingsScreen() {
return Uint8Array.from(bytes).buffer;
},
});
setPlaybackTestResult("Playback command completed.");
setPlaybackTestResult(null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("[Settings] Playback test failed", error);
@@ -878,6 +880,7 @@ export default function SettingsScreen() {
</View>
<SegmentedControl
size="sm"
hideLabels={Platform.OS !== "web"}
value={settings.theme}
onValueChange={handleThemeChange}
options={[
@@ -910,10 +913,7 @@ export default function SettingsScreen() {
<View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Test audio playback</Text>
<Text style={styles.aboutHintText}>
Plays a built-in PCM sample through the current app audio engine.
</Text>
<Text style={styles.audioRowTitle}>Test audio</Text>
{playbackTestResult ? (
<Text style={styles.aboutHintText}>{playbackTestResult}</Text>
) : null}
@@ -1463,7 +1463,7 @@ function DaemonCard({
>
{({ hovered = false, pressed = false }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.sm}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
@@ -13,9 +13,8 @@ import * as Clipboard from "expo-clipboard";
import {
ChevronDown,
Copy,
Folder,
GitBranch,
Ellipsis,
EllipsisVertical,
PanelRight,
Plus,
SquareTerminal,
@@ -1560,12 +1559,15 @@ function WorkspaceScreenContent({
accessibilityRole="button"
accessibilityLabel="Workspace actions"
>
{({ hovered, open }) => (
<Ellipsis
size={theme.iconSize.md}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
{({ hovered, open }) => {
const Icon = isMobile ? EllipsisVertical : Ellipsis;
return (
<Icon
size={theme.iconSize.md}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
);
}}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
@@ -1690,8 +1692,8 @@ function WorkspaceScreenContent({
{({ hovered }) => {
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return isGitCheckout
? <GitBranch size={theme.iconSize.lg} color={color} />
: <Folder size={theme.iconSize.lg} color={color} />;
? <SourceControlPanelIcon size={theme.iconSize.lg} color={color} strokeWidth={1.5} />
: <PanelRight size={theme.iconSize.lg} color={color} />;
}}
</HeaderToggleButton>
) : null}
+121 -3
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import { parseFileProtocolUrl, parseInlinePathToken } from "./inline-path";
import {
normalizeInlinePathTarget,
parseAssistantFileLink,
parseFileProtocolUrl,
parseInlinePathToken,
} from "./inline-path";
describe("parseInlinePathToken", () => {
it("returns null for plain paths (no line)", () => {
it("returns null for plain paths without a line number", () => {
expect(parseInlinePathToken("src/app.ts")).toBeNull();
expect(parseInlinePathToken("README.md")).toBeNull();
});
@@ -67,6 +72,119 @@ describe("parseFileProtocolUrl", () => {
it("rejects non-file URLs and invalid ranges", () => {
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
expect(
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")
).toBeNull();
});
});
describe("parseAssistantFileLink", () => {
it("parses absolute POSIX hrefs inside the active workspace", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
workspaceRoot: "/Users/test/project",
})
).toEqual({
raw: "/Users/test/project/src/app.tsx#L33",
path: "/Users/test/project/src/app.tsx",
lineStart: 33,
lineEnd: undefined,
});
});
it("parses absolute Windows hrefs inside the active workspace", () => {
expect(
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
workspaceRoot: "C:/repo",
})
).toEqual({
raw: "C:/repo/src/app.tsx#L12-L20",
path: "C:/repo/src/app.tsx",
lineStart: 12,
lineEnd: 20,
});
});
it("allows file URLs even when they are outside the workspace root", () => {
expect(
parseAssistantFileLink("file:///tmp/outside.txt", {
workspaceRoot: "/Users/test/project",
})
).toEqual({
raw: "file:///tmp/outside.txt",
path: "/tmp/outside.txt",
lineStart: undefined,
lineEnd: undefined,
});
});
it("rejects absolute hrefs outside the workspace root", () => {
expect(
parseAssistantFileLink("/tmp/outside.txt", {
workspaceRoot: "/Users/test/project",
})
).toBeNull();
});
it("rejects external URLs", () => {
expect(
parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")
).toBeNull();
});
it("rejects invalid line fragments", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
workspaceRoot: "/Users/test/project",
})
).toBeNull();
});
});
describe("normalizeInlinePathTarget", () => {
it("keeps relative file paths as file targets", () => {
expect(
normalizeInlinePathTarget("packages/app/src/components/message.tsx")
).toEqual({
directory: "packages/app/src/components",
file: "packages/app/src/components/message.tsx",
});
});
it("resolves absolute paths under cwd back to workspace-relative paths", () => {
expect(
normalizeInlinePathTarget(
"/Users/test/project/packages/app/src/components/message.tsx",
"/Users/test/project"
)
).toEqual({
directory: "packages/app/src/components",
file: "packages/app/src/components/message.tsx",
});
});
it("keeps absolute paths outside cwd as absolute file targets", () => {
expect(
normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")
).toEqual({
directory: "/tmp",
file: "/tmp/message.tsx",
});
});
it("treats cwd itself as the workspace root directory", () => {
expect(
normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")
).toEqual({
directory: ".",
});
});
it("keeps trailing-slash paths as directories", () => {
expect(
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project")
).toEqual({
directory: "packages/app",
});
});
});
+218 -14
View File
@@ -6,6 +6,16 @@ export interface InlinePathTarget {
}
const FILE_PROTOCOL = "file:";
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
}
export interface NormalizedInlinePathTarget {
directory: string;
file?: string;
}
function normalizePathToken(value: string): string | null {
const trimmed = value
@@ -20,6 +30,27 @@ function normalizePathToken(value: string): string | null {
return trimmed.replace(/\\/g, "/");
}
function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" | "lineEnd"> | null {
const rawFragment = value.startsWith("#") ? value.slice(1) : value;
if (!rawFragment) {
return { lineStart: undefined, lineEnd: undefined };
}
const lineMatch = rawFragment.match(INLINE_LINE_FRAGMENT);
const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined;
const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined;
if (
(lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) ||
(lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) ||
(lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart)
) {
return null;
}
return { lineStart, lineEnd };
}
/**
* Strict VSCode-style markers only.
*
@@ -103,29 +134,151 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
return null;
}
const rawFragment = parsedUrl.hash.startsWith("#")
? parsedUrl.hash.slice(1)
: parsedUrl.hash;
const lineMatch = rawFragment.match(/^L([0-9]+)(?:-L?([0-9]+))?$/i);
const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined;
const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined;
if (
(lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) ||
(lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) ||
(lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart)
) {
const lines = parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
}
return {
raw: value,
path: normalizedPath,
lineStart,
lineEnd,
...lines,
};
}
export function parseAssistantFileLink(
value: string,
options: AssistantHrefParseOptions = {}
): InlinePathTarget | null {
const fileUrlTarget = parseFileProtocolUrl(value);
if (fileUrlTarget) {
return fileUrlTarget;
}
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
if (trimmed.includes("://")) {
return null;
}
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw: value,
path: normalizedPath,
...lines,
};
}
if (!trimmed.startsWith("/")) {
return null;
}
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !normalizedPath.startsWith("/")) {
return null;
}
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
return null;
}
const lines = parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
}
return {
raw: value,
path: normalizedPath,
...lines,
};
}
export function normalizeInlinePathTarget(
rawPath: string,
cwd?: string
): NormalizedInlinePathTarget | null {
if (!rawPath) {
return null;
}
const normalizedInput = normalizePathInput(rawPath);
if (!normalizedInput) {
return null;
}
let normalized = normalizedInput;
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
if (cwdRelative) {
normalized = cwdRelative;
}
if (normalized.startsWith("./")) {
normalized = normalized.slice(2) || ".";
}
if (!normalized.length) {
normalized = ".";
}
if (normalized === ".") {
return { directory: "." };
}
if (normalized.endsWith("/")) {
const dir = normalized.replace(/\/+$/, "");
return { directory: dir.length > 0 ? dir : "." };
}
const lastSlash = normalized.lastIndexOf("/");
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
return {
directory: directory.length > 0 ? directory : ".",
file: normalized,
};
}
function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boolean {
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
if (!normalizedWorkspaceRoot) {
return true;
}
const comparePath = normalizePathForCompare(pathValue);
const compareWorkspaceRoot = normalizePathForCompare(
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/"
);
const comparePrefix =
compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
return (
comparePath === compareWorkspaceRoot ||
comparePath.startsWith(comparePrefix)
);
}
function normalizeFileUrlPath(pathname: string): string | null {
if (!pathname) {
return null;
@@ -142,3 +295,54 @@ function normalizeFileUrlPath(pathname: string): string | null {
return decoded;
}
function normalizePathInput(value: string | undefined): string | null {
if (!value) {
return null;
}
const trimmed = value
.trim()
.replace(/^['"`]/, "")
.replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
}
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
const normalizedCwd = normalizePathInput(cwd);
if (
!normalizedCwd ||
!isAbsolutePath(pathValue) ||
!isAbsolutePath(normalizedCwd)
) {
return null;
}
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
const comparePath = normalizePathForCompare(pathValue);
const compareCwd = normalizePathForCompare(normalizedCwdBase);
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
const comparePrefix = normalizePathForCompare(prefix);
if (comparePath === compareCwd) {
return ".";
}
if (comparePath.startsWith(comparePrefix)) {
return pathValue.slice(prefix.length) || ".";
}
return null;
}
function normalizePathForCompare(value: string): string {
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
}
function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.27",
"version": "0.1.29",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.27",
"@getpaseo/server": "0.1.27",
"@getpaseo/relay": "0.1.29",
"@getpaseo/server": "0.1.29",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+64 -8
View File
@@ -1,11 +1,18 @@
import type { Command } from 'commander'
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { getOrCreateServerId } from '@getpaseo/server'
import { getOrCreateServerId, findExecutable, applyProviderEnv } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
import { resolveNodePathFromPid } from './runtime-toolchain.js'
interface ProviderBinaryStatus {
label: string
path: string | null
version: string | null
}
interface DaemonStatus {
serverId: string | null
status: 'running' | 'stopped' | 'unresponsive'
@@ -21,6 +28,7 @@ interface DaemonStatus {
daemonNode: string
cliNode: string
cliVersion: string
providers: ProviderBinaryStatus[]
note?: string
}
@@ -77,16 +85,17 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
header: 'VALUE',
field: 'value',
color: (_, item) => {
if (item.key !== 'Status') {
return undefined
if (item.key === 'Status') {
if (item.value === 'running') return 'green'
if (item.value === 'unresponsive') return 'yellow'
return 'red'
}
if (item.value === 'running') {
if (item.key.startsWith(' ')) {
if (item.value === 'not found') return 'red'
if (item.value.endsWith('(--version failed)')) return 'yellow'
return 'green'
}
if (item.value === 'unresponsive') {
return 'yellow'
}
return 'red'
return undefined
},
},
],
@@ -126,9 +135,53 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
rows.push({ key: 'Note', value: status.note })
}
rows.push({ key: '', value: '' })
rows.push({ key: 'Providers', value: '' })
for (const provider of status.providers) {
if (!provider.path) {
rows.push({ key: ` ${provider.label}`, value: 'not found' })
} else if (!provider.version) {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` })
} else {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` })
}
}
return rows
}
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: 'Claude', binary: 'claude' },
{ label: 'Codex', binary: 'codex' },
{ label: 'OpenCode', binary: 'opencode' },
]
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
const binaryPath = findExecutable(binary)
if (!binaryPath) {
return { path: null, version: null }
}
const env = applyProviderEnv(process.env)
try {
const output = execFileSync(binaryPath, ['--version'], {
encoding: 'utf8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'pipe'],
env,
}).trim()
return { path: binaryPath, version: output || null }
} catch {
return { path: binaryPath, version: null }
}
}
function checkProviderBinaries(): ProviderBinaryStatus[] {
return PROVIDER_BINARIES.map(({ label, binary }) => {
const result = checkProviderBinary(binary)
return { label, ...result }
})
}
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
if (uid === undefined && !hostname) {
return null
@@ -201,6 +254,8 @@ export async function runStatusCommand(
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`)
}
const providers = checkProviderBinaries()
const daemonStatus: DaemonStatus = {
serverId,
status,
@@ -216,6 +271,7 @@ export async function runStatusCommand(
daemonNode,
cliNode,
cliVersion,
providers,
note,
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.27",
"version": "0.1.29",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "paseo"
version = "0.1.27"
version = "0.1.29"
description = "Paseo Desktop"
authors = ["moboudra"]
license = "MIT"
@@ -53,3 +53,5 @@ mac-notification-sys = "0.6"
+2 -2
View File
@@ -7,9 +7,9 @@
<key>CFBundleName</key>
<string>Paseo</string>
<key>CFBundleShortVersionString</key>
<string>0.1.27</string>
<string>0.1.29</string>
<key>CFBundleVersion</key>
<string>0.1.27</string>
<string>0.1.29</string>
<key>NSMicrophoneUsageDescription</key>
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
</dict>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Paseo",
"version": "0.1.27",
"version": "0.1.29",
"identifier": "dev.paseo.desktop",
"build": {
"frontendDist": "../../app/dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.4",
"version": "0.1.29",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.27",
"version": "0.1.29",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.27",
"version": "0.1.29",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -64,7 +64,7 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.27",
"@getpaseo/relay": "0.1.29",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -92,6 +92,25 @@ describe("applyProviderEnv", () => {
expect(env.FOO).toBe("bar");
expect(Object.keys(env).length).toBeGreaterThanOrEqual(3);
});
test("shell env PATH wins over base env PATH", () => {
const base = { PATH: "/usr/bin:/bin" };
const shellEnv = { PATH: "/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin" };
const env = applyProviderEnv(base, undefined, shellEnv);
expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin");
});
test("runtimeSettings env wins over shell env", () => {
const base = { PATH: "/usr/bin" };
const shellEnv = { PATH: "/usr/local/bin:/usr/bin" };
const runtime: ProviderRuntimeSettings = { env: { PATH: "/custom/path" } };
const env = applyProviderEnv(base, runtime, shellEnv);
expect(env.PATH).toBe("/custom/path");
});
});
describe("findExecutable", () => {
@@ -131,8 +131,8 @@ export function applyProviderEnv(
shellEnv?: Record<string, string>
): Record<string, string | undefined> {
return {
...(shellEnv ?? resolveShellEnv()),
...baseEnv,
...(shellEnv ?? resolveShellEnv()),
...(runtimeSettings?.env ?? {}),
};
}
@@ -59,9 +59,14 @@ const CODEX_MODES: AgentMode[] = [
const OPENCODE_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard permission rules",
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
},
];
@@ -97,11 +102,11 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
label: "OpenCode",
description:
"Open-source coding assistant with multi-provider model support",
defaultModeId: "default",
defaultModeId: "build",
modes: OPENCODE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModeId: "build",
},
},
];
@@ -1,5 +1,12 @@
import { beforeAll, describe, expect, test, vi } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
@@ -202,4 +209,72 @@ const hasOpenCode = isBinaryInstalled("opencode");
60_000
);
test(
"available modes include build and plan",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
expect(modes.some((mode) => mode.id === "build")).toBe(true);
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
60_000
);
test(
"plan mode blocks edits while build mode can write files",
async () => {
const cwd = tmpCwd();
const planFile = path.join(cwd, "plan-mode-output.txt");
const buildFile = path.join(cwd, "build-mode-output.txt");
const client = new OpenCodeAgentClient(logger);
const planSession = await client.createSession({
...buildConfig(cwd),
modeId: "plan",
});
const planTurn = await collectTurnEvents(
planSession.stream(
"Create a file named plan-mode-output.txt in the current directory containing exactly hello."
)
);
expect(planTurn.turnCompleted).toBe(true);
expect(planTurn.turnFailed).toBe(false);
expect(existsSync(planFile)).toBe(false);
const planResponse = planTurn.assistantMessages.map((message) => message.text).join("");
expect(planResponse.toLowerCase()).toContain("plan mode");
await planSession.close();
const buildSession = await client.createSession({
...buildConfig(cwd),
modeId: "build",
});
const buildTurn = await collectTurnEvents(
buildSession.stream(
"Create a file named build-mode-output.txt in the current directory containing exactly hello."
)
);
expect(buildTurn.turnCompleted).toBe(true);
expect(buildTurn.turnFailed).toBe(false);
expect(existsSync(buildFile)).toBe(true);
expect(readFileSync(buildFile, "utf8")).toContain("hello");
await buildSession.close();
rmSync(cwd, { recursive: true, force: true });
},
180_000
);
});
@@ -39,7 +39,7 @@ import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
@@ -47,12 +47,19 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const DEFAULT_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard permission rules",
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
},
];
const OPENCODE_MODE_IDS = new Set(DEFAULT_MODES.map((mode) => mode.id));
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
type OpenCodeMessageRole = "user" | "assistant";
@@ -227,6 +234,26 @@ function resolvePartDedupeKey(
return null;
}
function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
const trimmed = typeof modeId === "string" ? modeId.trim() : "";
if (!trimmed || trimmed === "default") {
return "build";
}
return trimmed;
}
function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
const order = new Map(DEFAULT_MODES.map((mode, index) => [mode.id, index]));
return [...modes].sort((left, right) => {
const leftOrder = order.get(left.id) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = order.get(right.id) ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.label.localeCompare(right.label);
});
}
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
@@ -794,6 +821,7 @@ class OpenCodeAgentSession implements AgentSession {
private streamedPartKeys = new Set<string>();
/** Tracks assistant messages already emitted from structured payloads. */
private emittedStructuredMessageIds = new Set<string>();
private availableModesCache: AgentMode[] | null = null;
constructor(
config: OpenCodeAgentConfig,
@@ -803,6 +831,7 @@ class OpenCodeAgentSession implements AgentSession {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
}
get id(): string | null {
@@ -871,6 +900,7 @@ class OpenCodeAgentSession implements AgentSession {
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant =
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
// Send prompt asynchronously
const promptResponse = await this.client.session.promptAsync({
@@ -887,6 +917,7 @@ class OpenCodeAgentSession implements AgentSession {
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
@@ -1025,7 +1056,32 @@ class OpenCodeAgentSession implements AgentSession {
}
async getAvailableModes(): Promise<AgentMode[]> {
return DEFAULT_MODES;
if (this.availableModesCache) {
return this.availableModesCache;
}
const response = await this.client.app.agents({
directory: this.config.cwd,
});
const discoveredModes =
response.error || !response.data
? []
: response.data
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.filter((agent) => OPENCODE_MODE_IDS.has(agent.name))
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
description:
typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim()
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
this.availableModesCache =
discoveredModes.length > 0 ? sortOpenCodeModes(discoveredModes) : DEFAULT_MODES;
return this.availableModesCache;
}
async getCurrentMode(): Promise<string | null> {
@@ -1033,7 +1089,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
this.currentMode = modeId;
this.currentMode = normalizeOpenCodeModeId(modeId);
}
getPendingPermissions(): AgentPermissionRequest[] {
+3
View File
@@ -24,6 +24,9 @@ export {
type SherpaLoaderEnvResolution,
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Provider binary resolution
export { findExecutable, applyProviderEnv } from "./agent/provider-launch-config.js";
// Agent SDK types for CLI commands
export type {
AgentMode,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.27",
"version": "0.1.29",
"private": true,
"type": "module",
"scripts": {
File diff suppressed because it is too large Load Diff
+63
View File
@@ -10,7 +10,10 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as OpencodeRouteImport } from './routes/opencode'
import { Route as DocsRouteImport } from './routes/docs'
import { Route as CodexRouteImport } from './routes/codex'
import { Route as ClaudeCodeRouteImport } from './routes/claude-code'
import { Route as ChangelogRouteImport } from './routes/changelog'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsIndexRouteImport } from './routes/docs/index'
@@ -27,11 +30,26 @@ const PrivacyRoute = PrivacyRouteImport.update({
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any)
const OpencodeRoute = OpencodeRouteImport.update({
id: '/opencode',
path: '/opencode',
getParentRoute: () => rootRouteImport,
} as any)
const DocsRoute = DocsRouteImport.update({
id: '/docs',
path: '/docs',
getParentRoute: () => rootRouteImport,
} as any)
const CodexRoute = CodexRouteImport.update({
id: '/codex',
path: '/codex',
getParentRoute: () => rootRouteImport,
} as any)
const ClaudeCodeRoute = ClaudeCodeRouteImport.update({
id: '/claude-code',
path: '/claude-code',
getParentRoute: () => rootRouteImport,
} as any)
const ChangelogRoute = ChangelogRouteImport.update({
id: '/changelog',
path: '/changelog',
@@ -86,7 +104,10 @@ const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/docs': typeof DocsRouteWithChildren
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
@@ -100,6 +121,9 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
@@ -114,7 +138,10 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/docs': typeof DocsRouteWithChildren
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
@@ -130,7 +157,10 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/docs'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
@@ -144,6 +174,9 @@ export interface FileRouteTypes {
to:
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
@@ -157,7 +190,10 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/docs'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
@@ -172,7 +208,10 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ChangelogRoute: typeof ChangelogRoute
ClaudeCodeRoute: typeof ClaudeCodeRoute
CodexRoute: typeof CodexRoute
DocsRoute: typeof DocsRouteWithChildren
OpencodeRoute: typeof OpencodeRoute
PrivacyRoute: typeof PrivacyRoute
}
@@ -185,6 +224,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
'/opencode': {
id: '/opencode'
path: '/opencode'
fullPath: '/opencode'
preLoaderRoute: typeof OpencodeRouteImport
parentRoute: typeof rootRouteImport
}
'/docs': {
id: '/docs'
path: '/docs'
@@ -192,6 +238,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsRouteImport
parentRoute: typeof rootRouteImport
}
'/codex': {
id: '/codex'
path: '/codex'
fullPath: '/codex'
preLoaderRoute: typeof CodexRouteImport
parentRoute: typeof rootRouteImport
}
'/claude-code': {
id: '/claude-code'
path: '/claude-code'
fullPath: '/claude-code'
preLoaderRoute: typeof ClaudeCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/changelog': {
id: '/changelog'
path: '/changelog'
@@ -292,7 +352,10 @@ const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ChangelogRoute: ChangelogRoute,
ClaudeCodeRoute: ClaudeCodeRoute,
CodexRoute: CodexRoute,
DocsRoute: DocsRouteWithChildren,
OpencodeRoute: OpencodeRoute,
PrivacyRoute: PrivacyRoute,
}
export const routeTree = rootRouteImport
@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'
import { LandingPage } from '~/components/landing-page'
export const Route = createFileRoute('/claude-code')({
head: () => ({
meta: [
{ title: 'Claude Code Mobile App – Ship from your phone | Paseo' },
{
name: 'description',
content:
'Run Claude Code from your phone. Launch agents, check on progress, review diffs, and merge — all from your pocket. Self-hosted, your code stays on your machine.',
},
],
}),
component: ClaudeCodePage,
})
function ClaudeCodePage() {
return (
<LandingPage
title="Ship with Claude Code from your phone"
subtitle="Launch agents, check on progress, and merge from anywhere. Your Claude Code setup, your machine, your pocket."
/>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'
import { LandingPage } from '~/components/landing-page'
export const Route = createFileRoute('/codex')({
head: () => ({
meta: [
{ title: 'Codex Mobile App – Run Codex from anywhere | Paseo' },
{
name: 'description',
content:
'Run OpenAI Codex from your phone. Kick off agents, monitor progress, and ship code without being at your desk. Self-hosted, your code never leaves your machine.',
},
],
}),
component: CodexPage,
})
function CodexPage() {
return (
<LandingPage
title="Run Codex from anywhere"
subtitle="Kick off Codex agents on your machine from your phone. Check in on the train, review on the couch, merge from the park."
/>
)
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'
import { LandingPage } from '~/components/landing-page'
export const Route = createFileRoute('/opencode')({
head: () => ({
meta: [
{ title: 'OpenCode Mobile App – Code from anywhere | Paseo' },
{
name: 'description',
content:
'Run OpenCode from your phone. Launch agents, watch them work, and ship code from wherever you are. Self-hosted, open source, your code stays local.',
},
],
}),
component: OpenCodePage,
})
function OpenCodePage() {
return (
<LandingPage
title="Run OpenCode from your phone"
subtitle="Launch agents, check on builds, and ship code from anywhere. Same setup, same machine, just not at your desk."
/>
)
}