mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
12 Commits
desktop-v0
...
feat/docto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7960ec60 | ||
|
|
90c37e2f95 | ||
|
|
59e0b2ec62 | ||
|
|
15d7763d0b | ||
|
|
3a4b463deb | ||
|
|
bb737dea52 | ||
|
|
7b22fc5c3f | ||
|
|
a15b52efc8 | ||
|
|
7f11b93e0f | ||
|
|
02d74777b2 | ||
|
|
cf148ba3af | ||
|
|
19b6aaa2f3 |
53
.github/workflows/desktop-release.yml
vendored
53
.github/workflows/desktop-release.yml
vendored
@@ -5,12 +5,25 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
- "desktop-v*"
|
||||
- "desktop-macos-v*"
|
||||
- "desktop-linux-v*"
|
||||
- "desktop-windows-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
platform:
|
||||
description: "Optional desktop platform to build."
|
||||
required: false
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- macos
|
||||
- linux
|
||||
- windows
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
@@ -21,6 +34,7 @@ env:
|
||||
|
||||
jobs:
|
||||
publish-macos:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -44,7 +58,13 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" == desktop-v* ]]; then
|
||||
if [[ "$source_tag" == desktop-windows-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-windows-v}"
|
||||
elif [[ "$source_tag" == desktop-linux-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-linux-v}"
|
||||
elif [[ "$source_tag" == desktop-macos-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-macos-v}"
|
||||
elif [[ "$source_tag" == desktop-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-v}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
@@ -69,13 +89,6 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Lightning CSS Windows binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(node -p "require('./package.json').overrides.lightningcss")"
|
||||
npm install --no-save "lightningcss-win32-x64-msvc@${version}"
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
@@ -89,7 +102,7 @@ jobs:
|
||||
const rawTag = process.env.SOURCE_TAG;
|
||||
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
@@ -153,6 +166,7 @@ jobs:
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -169,7 +183,13 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" == desktop-v* ]]; then
|
||||
if [[ "$source_tag" == desktop-windows-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-windows-v}"
|
||||
elif [[ "$source_tag" == desktop-linux-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-linux-v}"
|
||||
elif [[ "$source_tag" == desktop-macos-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-macos-v}"
|
||||
elif [[ "$source_tag" == desktop-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-v}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
@@ -210,7 +230,7 @@ jobs:
|
||||
const rawTag = process.env.SOURCE_TAG;
|
||||
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
@@ -268,6 +288,7 @@ jobs:
|
||||
args: --bundles appimage
|
||||
|
||||
publish-windows:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -284,7 +305,13 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" == desktop-v* ]]; then
|
||||
if [[ "$source_tag" == desktop-windows-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-windows-v}"
|
||||
elif [[ "$source_tag" == desktop-linux-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-linux-v}"
|
||||
elif [[ "$source_tag" == desktop-macos-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-macos-v}"
|
||||
elif [[ "$source_tag" == desktop-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-v}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
@@ -320,7 +347,7 @@ jobs:
|
||||
const rawTag = process.env.SOURCE_TAG;
|
||||
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# Runtime Simplification Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the managed runtime install/copy layer. The desktop app must run the bundled runtime in place from the application resources/install directory on macOS, Windows, and Linux.
|
||||
|
||||
This is a simplification task. If a change preserves the old install-manager shape under a new name, it fails the goal.
|
||||
|
||||
## Hard Requirements
|
||||
|
||||
1. The bundled runtime is read-only.
|
||||
The desktop app must not copy the bundled runtime to app data, temp, cache, or any other writable directory.
|
||||
|
||||
2. The app must execute Node/CLI/server entrypoints directly from the bundled runtime root.
|
||||
On macOS this means inside `Paseo.app/Contents/Resources/...`.
|
||||
On Windows and Linux this means inside the installed app resources directory.
|
||||
|
||||
3. There is exactly one runtime per installed app.
|
||||
Remove versioned installed runtime directories like `runtime/<runtime-id>` from the runtime execution path.
|
||||
|
||||
4. The CLI shim may point into the installed app bundle/directory.
|
||||
Do not keep an extra installed runtime tree just to preserve a stable shim target across updates.
|
||||
|
||||
5. All mutable state remains outside the bundled runtime.
|
||||
Logs, sockets/pipes, PID files, daemon state, `PASEO_HOME`, and any other writable files must continue to live in managed home / app data locations.
|
||||
|
||||
6. Runtime discovery must stay cross-platform.
|
||||
The implementation must resolve the bundled runtime/resources path on macOS, Windows, and Linux using the app install/resources directory, not hardcoded `.app` assumptions.
|
||||
|
||||
7. Remove dead machinery, do not leave adapters behind.
|
||||
If install/copy/versioned-runtime code becomes unused, delete it instead of keeping fallback paths "just in case".
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Do not change how the runtime is built into the desktop app bundle in this task.
|
||||
This task is about runtime execution and path management, not bundling format.
|
||||
|
||||
2. Do not reintroduce a second runtime location for migration compatibility.
|
||||
It is acceptable if older installed clients do not migrate cleanly.
|
||||
|
||||
3. Do not add feature flags, env-guarded fallback paths, or compatibility shims unless absolutely required by a real platform constraint proven in code.
|
||||
|
||||
## Concrete Implementation Direction
|
||||
|
||||
1. Treat `bundled_runtime_root(app)` plus `current-runtime.json` as the runtime source of truth.
|
||||
|
||||
2. Replace `paths.runtime_root` usage for runtime execution with the bundled runtime root selected by `current-runtime.json`.
|
||||
|
||||
3. Delete `install_runtime_if_needed(...)` and related copy/install staging logic if nothing else still needs it.
|
||||
|
||||
4. Rework CLI shim generation so the inner launcher points at the bundled runtime's Node + CLI entrypoint directly, while still keeping mutable state (`PASEO_HOME`) outside the runtime.
|
||||
|
||||
5. Simplify `ManagedPaths` and related structs if `runtime_root` and `stable_runtime_root` no longer need separate installed-runtime semantics.
|
||||
|
||||
6. Keep diagnostics/status reporting accurate.
|
||||
If the app reports bundled vs installed runtime roots today, update that output to reflect the new single-runtime model.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. No code path copies the bundled runtime tree into app data before launching the daemon or CLI.
|
||||
|
||||
2. No code path depends on a versioned installed runtime directory for execution.
|
||||
|
||||
3. The CLI shim and daemon launch path both resolve to bundled runtime executables/resources.
|
||||
|
||||
4. Typecheck passes.
|
||||
|
||||
5. Relevant desktop/runtime tests pass, updated to reflect the new direct-from-bundle model.
|
||||
|
||||
6. The resulting implementation is materially simpler:
|
||||
fewer runtime path concepts, fewer staging/install branches, fewer indirections.
|
||||
|
||||
## Review Bar
|
||||
|
||||
The implementation should be rejected if:
|
||||
|
||||
- it still copies the runtime anywhere before execution
|
||||
- it keeps runtime version directories in the execution path
|
||||
- it preserves the stable installed runtime launcher concept without a hard platform reason
|
||||
- it adds migration complexity for old installs
|
||||
- it introduces new fallback branches instead of removing obsolete ones
|
||||
47
docs/plans/2026-03-09-doctor-health-check-design.md
Normal file
47
docs/plans/2026-03-09-doctor-health-check-design.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Paseo Doctor / Health Check
|
||||
|
||||
## Problem
|
||||
|
||||
Users need a way to diagnose their Paseo setup: are agent binaries installed, what versions are running, is the config valid? Currently there's no unified diagnostic — errors only surface when you try to launch an agent and it fails.
|
||||
|
||||
## Decision: Shared library + HTTP endpoint
|
||||
|
||||
The check logic lives as a pure module in `packages/server` (no daemon dependency). It inspects the local filesystem and runs `which`/`--version` commands.
|
||||
|
||||
- **Daemon** exposes it via `GET /api/doctor` (stateless, no WS session needed).
|
||||
- **CLI** imports the module directly for offline use (`paseo doctor`), or calls the HTTP endpoint with `--remote`.
|
||||
- **App** calls the HTTP endpoint from the settings screen.
|
||||
|
||||
## Data Model
|
||||
|
||||
```typescript
|
||||
type CheckStatus = "ok" | "warn" | "error";
|
||||
|
||||
interface DoctorCheckResult {
|
||||
id: string; // e.g. "provider.claude.binary"
|
||||
label: string; // e.g. "Claude CLI"
|
||||
status: CheckStatus;
|
||||
detail: string; // e.g. "/usr/local/bin/claude (v1.0.42)"
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
checks: DoctorCheckResult[];
|
||||
summary: { ok: number; warn: number; error: number };
|
||||
timestamp: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Checks
|
||||
|
||||
| ID | What it checks | ok | warn | error |
|
||||
|---|---|---|---|---|
|
||||
| provider.claude.binary | which claude | Found + path | — | Not found |
|
||||
| provider.claude.version | claude --version | Version string | Parse failed | Binary missing |
|
||||
| provider.codex.binary | which codex | Found + path | — | Not found |
|
||||
| provider.codex.version | codex --version | Version string | Parse failed | Binary missing |
|
||||
| provider.opencode.binary | which opencode | Found + path | — | Not found |
|
||||
| provider.opencode.version | opencode --version | Version string | Parse failed | Binary missing |
|
||||
| config.valid | Parse config.json | Valid | — | Parse/schema error |
|
||||
| config.listen | Listen address format | Valid | — | Malformed |
|
||||
| runtime.node | Node.js version | Version | — | Not found |
|
||||
| runtime.paseo | Paseo daemon version | Version | — | Unknown |
|
||||
12809
package-lock.json
generated
12809
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,6 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
config.transformer.asyncRequireModulePath = require.resolve(
|
||||
"@expo/metro-config/build/async-require"
|
||||
);
|
||||
|
||||
function isLocalModuleImport(moduleName) {
|
||||
return (
|
||||
|
||||
@@ -6,187 +6,126 @@ import {
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import { buildHostWorkspaceAgentRoute } from "@/utils/host-routes";
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useCallback, useMemo, useState, type ReactElement } from 'react'
|
||||
import { router, usePathname, type Href } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import { shortenPath } from '@/utils/shorten-path'
|
||||
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { AgentStatusDot } from '@/components/agent-status-dot'
|
||||
import { buildAgentNavigationKey, startNavigationTiming } from '@/utils/navigation-timing'
|
||||
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
showCheckoutInfo?: boolean;
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
selectedAgentId?: string;
|
||||
onAgentSelect?: () => void;
|
||||
listFooterComponent?: ReactElement | null;
|
||||
agents: AggregatedAgent[]
|
||||
showCheckoutInfo?: boolean
|
||||
isRefreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
selectedAgentId?: string
|
||||
onAgentSelect?: () => void
|
||||
listFooterComponent?: ReactElement | null
|
||||
}
|
||||
|
||||
interface AgentListSection {
|
||||
key: string;
|
||||
title: string;
|
||||
data: AggregatedAgent[];
|
||||
key: string
|
||||
title: string
|
||||
data: AggregatedAgent[]
|
||||
}
|
||||
|
||||
type SessionColumnKey = "session" | "project" | "host" | "status" | "updated";
|
||||
|
||||
interface SessionColumnDefinition {
|
||||
key: SessionColumnKey;
|
||||
label: string;
|
||||
flex: number;
|
||||
align?: "left" | "right";
|
||||
mobile?: boolean;
|
||||
requiresMultiHost?: boolean;
|
||||
}
|
||||
|
||||
const SESSION_COLUMNS: SessionColumnDefinition[] = [
|
||||
{ key: "session", label: "Session", flex: 2.3, mobile: true },
|
||||
{ key: "project", label: "Project", flex: 2.6 },
|
||||
{ key: "host", label: "Host", flex: 1.2, requiresMultiHost: true },
|
||||
{ key: "status", label: "Status", flex: 1.2, mobile: true },
|
||||
{ key: "updated", label: "Updated", flex: 1, align: "right", mobile: true },
|
||||
];
|
||||
|
||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
|
||||
const activityStart = new Date(
|
||||
lastActivityAt.getFullYear(),
|
||||
lastActivityAt.getMonth(),
|
||||
lastActivityAt.getDate()
|
||||
);
|
||||
)
|
||||
|
||||
if (activityStart.getTime() >= todayStart.getTime()) {
|
||||
return "Today";
|
||||
return 'Today'
|
||||
}
|
||||
if (activityStart.getTime() >= yesterdayStart.getTime()) {
|
||||
return "Yesterday";
|
||||
return 'Yesterday'
|
||||
}
|
||||
|
||||
const diffTime = todayStart.getTime() - activityStart.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
const diffTime = todayStart.getTime() - activityStart.getTime()
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
||||
if (diffDays <= 7) {
|
||||
return "This week";
|
||||
return 'This week'
|
||||
}
|
||||
if (diffDays <= 30) {
|
||||
return "This month";
|
||||
return 'This month'
|
||||
}
|
||||
return "Older";
|
||||
return 'Older'
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: AggregatedAgent["status"]): string {
|
||||
function formatStatusLabel(status: AggregatedAgent['status']): string {
|
||||
switch (status) {
|
||||
case "initializing":
|
||||
return "Starting";
|
||||
case "idle":
|
||||
return "Idle";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "error":
|
||||
return "Error";
|
||||
case "closed":
|
||||
return "Closed";
|
||||
case 'initializing':
|
||||
return 'Starting'
|
||||
case 'idle':
|
||||
return 'Idle'
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
case 'closed':
|
||||
return 'Closed'
|
||||
default:
|
||||
return status;
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleColumns(input: {
|
||||
isMobile: boolean;
|
||||
showHostColumn: boolean;
|
||||
}): SessionColumnDefinition[] {
|
||||
return SESSION_COLUMNS.filter((column) => {
|
||||
if (!input.showHostColumn && column.requiresMultiHost) {
|
||||
return false;
|
||||
}
|
||||
if (input.isMobile && !column.mobile) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function SessionCell({
|
||||
align = "left",
|
||||
flex,
|
||||
children,
|
||||
}: {
|
||||
align?: "left" | "right";
|
||||
flex: number;
|
||||
children: ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.cell,
|
||||
{ flex },
|
||||
align === "right" ? styles.cellRight : styles.cellLeft,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionBadge({
|
||||
label,
|
||||
tone = "neutral",
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
label: string
|
||||
tone?: 'neutral' | 'warning' | 'danger'
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
tone === "warning" && styles.badgeWarning,
|
||||
tone === "danger" && styles.badgeDanger,
|
||||
tone === 'warning' && styles.badgeWarning,
|
||||
tone === 'danger' && styles.badgeDanger,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.badgeText,
|
||||
tone === "warning" && styles.badgeTextWarning,
|
||||
tone === "danger" && styles.badgeTextDanger,
|
||||
tone === 'warning' && styles.badgeTextWarning,
|
||||
tone === 'danger' && styles.badgeTextDanger,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function SessionTableRow({
|
||||
function SessionRow({
|
||||
agent,
|
||||
columns,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
columns: SessionColumnDefinition[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
onPress: (agent: AggregatedAgent) => void;
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
agent: AggregatedAgent
|
||||
isMobile: boolean
|
||||
selectedAgentId?: string
|
||||
onPress: (agent: AggregatedAgent) => void
|
||||
onLongPress: (agent: AggregatedAgent) => void
|
||||
}) {
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt)
|
||||
const agentKey = `${agent.serverId}:${agent.id}`
|
||||
const isSelected = selectedAgentId === agentKey
|
||||
const statusLabel = formatStatusLabel(agent.status)
|
||||
const projectPath = shortenPath(agent.cwd)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -200,161 +139,90 @@ function SessionTableRow({
|
||||
onLongPress={() => onLongPress(agent)}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<View style={styles.rowInner}>
|
||||
{columns.map((column) => {
|
||||
if (column.key === "session") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.primaryCell}>
|
||||
<View style={styles.sessionTitleRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sessionTitle,
|
||||
(isSelected || hovered) && styles.sessionTitleHighlighted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
|
||||
{(agent.pendingPermissionCount ?? 0) > 0 ? (
|
||||
<SessionBadge
|
||||
label={`${agent.pendingPermissionCount} pending`}
|
||||
tone="warning"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{isMobile ? (
|
||||
<View style={styles.sessionMetaRow}>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
|
||||
{agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.secondaryBadgeRow}>
|
||||
{agent.requiresAttention ? (
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "project") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.projectCell}>
|
||||
<Text style={styles.projectPath} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.projectProvider} numberOfLines={1}>
|
||||
{agent.provider}
|
||||
</Text>
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "host") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text style={styles.hostText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "status") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.statusCell}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
/>
|
||||
<Text style={styles.statusText} numberOfLines={1}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text style={styles.updatedText} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
);
|
||||
})}
|
||||
<View style={styles.rowLeading}>
|
||||
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
|
||||
</View>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Text
|
||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || 'New session'}
|
||||
</Text>
|
||||
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
|
||||
{(agent.pendingPermissionCount ?? 0) > 0 ? (
|
||||
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
|
||||
) : null}
|
||||
{!isMobile && agent.requiresAttention ? (
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
) : null}
|
||||
</View>
|
||||
{isMobile && (
|
||||
<View style={styles.rowMetaRow}>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
{agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Text style={styles.columnMeta} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.columnMetaFixed}>{statusLabel}</Text>
|
||||
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
|
||||
</>
|
||||
)}
|
||||
{isMobile && agent.requiresAttention ? (
|
||||
<View style={styles.rowTrailing}>
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function SessionTableSection({
|
||||
section,
|
||||
columns,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
onAgentPress,
|
||||
onAgentLongPress,
|
||||
}: {
|
||||
section: AgentListSection;
|
||||
columns: SessionColumnDefinition[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
onAgentPress: (agent: AggregatedAgent) => void;
|
||||
onAgentLongPress: (agent: AggregatedAgent) => void;
|
||||
section: AgentListSection
|
||||
isMobile: boolean
|
||||
selectedAgentId?: string
|
||||
onAgentPress: (agent: AggregatedAgent) => void
|
||||
onAgentLongPress: (agent: AggregatedAgent) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.sectionBlock}>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
|
||||
<View style={styles.tableCard}>
|
||||
<View style={styles.tableHeader}>
|
||||
{columns.map((column) => (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text
|
||||
style={[
|
||||
styles.columnLabel,
|
||||
column.align === "right" && styles.columnLabelRight,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{column.label}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.listCard}>
|
||||
{section.data.map((agent, index) => (
|
||||
<View
|
||||
key={`${agent.serverId}:${agent.id}`}
|
||||
style={index > 0 ? styles.rowDivider : undefined}
|
||||
>
|
||||
<SessionTableRow
|
||||
<SessionRow
|
||||
agent={agent}
|
||||
columns={columns}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onPress={onAgentPress}
|
||||
@@ -364,7 +232,7 @@ function SessionTableSection({
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
@@ -375,110 +243,96 @@ export function AgentList({
|
||||
onAgentSelect,
|
||||
listFooterComponent,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const { theme } = useUnistyles()
|
||||
const pathname = usePathname()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
|
||||
);
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null
|
||||
)
|
||||
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
|
||||
const showHostColumn = useMemo(
|
||||
() => new Set(agents.map((agent) => agent.serverId)).size > 1,
|
||||
[agents]
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => getVisibleColumns({ isMobile, showHostColumn }),
|
||||
[isMobile, showHostColumn]
|
||||
);
|
||||
const isActionSheetVisible = actionAgent !== null
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient)
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
if (isActionSheetVisible) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||
const serverId = agent.serverId
|
||||
const agentId = agent.id
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId)
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: "home",
|
||||
to: "agent",
|
||||
from: 'home',
|
||||
to: 'agent',
|
||||
params: { serverId, agentId },
|
||||
});
|
||||
})
|
||||
|
||||
const shouldReplace = pathname.startsWith("/h/");
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
const shouldReplace = pathname.startsWith('/h/')
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
|
||||
onAgentSelect?.();
|
||||
onAgentSelect?.()
|
||||
|
||||
const route: Href = buildHostWorkspaceAgentRoute(
|
||||
serverId,
|
||||
agent.cwd,
|
||||
agentId
|
||||
) as Href;
|
||||
navigate(route);
|
||||
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
|
||||
navigate(route)
|
||||
},
|
||||
[isActionSheetVisible, pathname, onAgentSelect]
|
||||
);
|
||||
)
|
||||
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
setActionAgent(agent);
|
||||
}, []);
|
||||
setActionAgent(agent)
|
||||
}, [])
|
||||
|
||||
const handleCloseActionSheet = useCallback(() => {
|
||||
setActionAgent(null);
|
||||
}, []);
|
||||
setActionAgent(null)
|
||||
}, [])
|
||||
|
||||
const handleArchiveAgent = useCallback(() => {
|
||||
if (!actionAgent || !actionClient) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
void actionClient.archiveAgent(actionAgent.id);
|
||||
setActionAgent(null);
|
||||
}, [actionAgent, actionClient]);
|
||||
void actionClient.archiveAgent(actionAgent.id)
|
||||
setActionAgent(null)
|
||||
}, [actionAgent, actionClient])
|
||||
|
||||
const sections = useMemo((): AgentListSection[] => {
|
||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||
const buckets = new Map<string, AggregatedAgent[]>();
|
||||
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const
|
||||
const buckets = new Map<string, AggregatedAgent[]>()
|
||||
for (const agent of agents) {
|
||||
const label = deriveDateSectionLabel(agent.lastActivityAt);
|
||||
const existing = buckets.get(label) ?? [];
|
||||
existing.push(agent);
|
||||
buckets.set(label, existing);
|
||||
const label = deriveDateSectionLabel(agent.lastActivityAt)
|
||||
const existing = buckets.get(label) ?? []
|
||||
existing.push(agent)
|
||||
buckets.set(label, existing)
|
||||
}
|
||||
|
||||
const result: AgentListSection[] = [];
|
||||
const result: AgentListSection[] = []
|
||||
for (const label of order) {
|
||||
const data = buckets.get(label);
|
||||
const data = buckets.get(label)
|
||||
if (!data || data.length === 0) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
result.push({ key: `date:${label}`, title: label, data });
|
||||
result.push({ key: `date:${label}`, title: label, data })
|
||||
}
|
||||
return result;
|
||||
}, [agents]);
|
||||
return result
|
||||
}, [agents])
|
||||
|
||||
const renderSection: ListRenderItem<AgentListSection> = useCallback(
|
||||
({ item: section }) => (
|
||||
<SessionTableSection
|
||||
section={section}
|
||||
columns={columns}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentPress={handleAgentPress}
|
||||
onAgentLongPress={handleAgentLongPress}
|
||||
/>
|
||||
),
|
||||
[columns, handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId]
|
||||
);
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId]
|
||||
)
|
||||
|
||||
const keyExtractor = useCallback((section: AgentListSection) => section.key, []);
|
||||
const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -510,10 +364,7 @@ export function AgentList({
|
||||
onRequestClose={handleCloseActionSheet}
|
||||
>
|
||||
<View style={styles.sheetOverlay}>
|
||||
<Pressable
|
||||
style={styles.sheetBackdrop}
|
||||
onPress={handleCloseActionSheet}
|
||||
/>
|
||||
<Pressable style={styles.sheetBackdrop} onPress={handleCloseActionSheet} />
|
||||
<View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
@@ -522,7 +373,7 @@ export function AgentList({
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
|
||||
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -552,7 +403,7 @@ export function AgentList({
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -573,118 +424,92 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "600",
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionLine: {
|
||||
flex: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tableCard: {
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
listCard: {
|
||||
overflow: {
|
||||
xs: 'hidden' as const,
|
||||
md: 'visible' as const,
|
||||
},
|
||||
borderRadius: {
|
||||
xs: theme.borderRadius.lg,
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
columnLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "600",
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
columnLabelRight: {
|
||||
textAlign: "right",
|
||||
},
|
||||
rowDivider: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: theme.colors.surface2,
|
||||
borderTopWidth: {
|
||||
xs: StyleSheet.hairlineWidth,
|
||||
md: 0,
|
||||
},
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: {
|
||||
xs: theme.borderRadius.lg,
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[3],
|
||||
marginBottom: {
|
||||
xs: theme.spacing[1],
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
rowInner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
rowLeading: {
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowMetaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing[1],
|
||||
marginTop: 2,
|
||||
},
|
||||
rowTrailing: {
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
rowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
cell: {
|
||||
minWidth: 0,
|
||||
},
|
||||
cellLeft: {
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
cellRight: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
primaryCell: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sessionTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sessionTitle: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "500",
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: '500',
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.86,
|
||||
},
|
||||
sessionTitleHighlighted: {
|
||||
opacity: 1,
|
||||
},
|
||||
sessionMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sessionMetaText: {
|
||||
maxWidth: "100%",
|
||||
maxWidth: '100%',
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
@@ -693,41 +518,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.7,
|
||||
},
|
||||
secondaryBadgeRow: {
|
||||
minHeight: theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectCell: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
projectPath: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
projectProvider: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
hostText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
statusCell: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
updatedText: {
|
||||
columnMeta: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "right",
|
||||
flexShrink: 1,
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
marginLeft: theme.spacing[4],
|
||||
},
|
||||
columnMetaFixed: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
width: 72,
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
@@ -736,17 +540,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgeWarning: {
|
||||
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
||||
},
|
||||
badgeDanger: {
|
||||
backgroundColor: "rgba(239, 68, 68, 0.14)",
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.14)',
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "600",
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
badgeTextWarning: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
@@ -756,26 +558,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
sheetOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheetBackdrop: {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.35)",
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
},
|
||||
sheetContainer: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
borderTopLeftRadius: theme.borderRadius['2xl'],
|
||||
borderTopRightRadius: theme.borderRadius['2xl'],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[4],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
sheetHandle: {
|
||||
alignSelf: "center",
|
||||
alignSelf: 'center',
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
@@ -786,18 +588,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
textAlign: 'center',
|
||||
},
|
||||
sheetButtonRow: {
|
||||
flexDirection: "row",
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
sheetButton: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sheetArchiveButton: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
@@ -818,4 +620,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Platform, Pressable } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -90,7 +91,12 @@ function ControlledStatusBar({
|
||||
const { theme } = useUnistyles()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [prefsOpen, setPrefsOpen] = useState(false)
|
||||
const dropdownMaxWidth = isWeb ? 360 : undefined
|
||||
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
|
||||
|
||||
const providerAnchorRef = useRef<View>(null)
|
||||
const modeAnchorRef = useRef<View>(null)
|
||||
const modelAnchorRef = useRef<View>(null)
|
||||
const thinkingAnchorRef = useRef<View>(null)
|
||||
|
||||
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
|
||||
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
|
||||
@@ -119,18 +125,47 @@ function ControlledStatusBar({
|
||||
|
||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0
|
||||
|
||||
const SEARCH_THRESHOLD = 6
|
||||
|
||||
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
|
||||
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[providerOptions]
|
||||
)
|
||||
const comboboxModeOptions = useMemo<ComboboxOption[]>(
|
||||
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[modeOptions]
|
||||
)
|
||||
const comboboxModelOptions = useMemo<ComboboxOption[]>(
|
||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[modelOptions]
|
||||
)
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[thinkingOptions]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
|
||||
{isWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<>
|
||||
<Pressable
|
||||
ref={providerAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
(pressed || openSelector === 'provider') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectProvider) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
@@ -139,34 +174,31 @@ function ControlledStatusBar({
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-provider-menu"
|
||||
>
|
||||
{providerOptions.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
selected={provider.id === selectedProviderId}
|
||||
onSelect={() => onSelectProvider?.(provider.id)}
|
||||
>
|
||||
{provider.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxProviderOptions}
|
||||
value={selectedProviderId ?? ''}
|
||||
onSelect={(id) => onSelectProvider?.(id)}
|
||||
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'provider'}
|
||||
onOpenChange={handleOpenChange('provider')}
|
||||
anchorRef={providerAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<>
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
@@ -175,68 +207,60 @@ function ControlledStatusBar({
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-mode-menu"
|
||||
>
|
||||
{modeOptions.map((mode) => (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModeOptions}
|
||||
value={selectedModeId ?? ''}
|
||||
onSelect={(id) => onSelectMode?.(id)}
|
||||
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'mode'}
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
anchorRef={modeAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-model-menu"
|
||||
>
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<>
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
@@ -250,24 +274,18 @@ function ControlledStatusBar({
|
||||
/>
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-thinking-menu"
|
||||
>
|
||||
{thinkingOptions.map((thinking) => (
|
||||
<DropdownMenuItem
|
||||
key={thinking.id}
|
||||
selected={thinking.id === selectedThinkingOptionId}
|
||||
onSelect={() => onSelectThinkingOption?.(thinking.id)}
|
||||
>
|
||||
{thinking.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxThinkingOptions}
|
||||
value={selectedThinkingOptionId ?? ''}
|
||||
onSelect={(id) => onSelectThinkingOption?.(id)}
|
||||
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'thinking'}
|
||||
onOpenChange={handleOpenChange('thinking')}
|
||||
anchorRef={thinkingAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight, Plus } from 'lucide-react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { ChevronDown, ChevronRight, MoreVertical, Plus } from 'lucide-react-native'
|
||||
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||
@@ -50,6 +51,12 @@ import {
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SyncedLoader } from '@/components/synced-loader'
|
||||
import { useToast } from '@/contexts/toast-context'
|
||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||
@@ -112,6 +119,11 @@ interface WorkspaceRowInnerProps {
|
||||
isArchiving: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
archiveLabel?: string
|
||||
archiveStatus?: 'idle' | 'pending' | 'success'
|
||||
archivePendingLabel?: string
|
||||
onArchive?: () => void
|
||||
onCopyPath?: () => void
|
||||
}
|
||||
|
||||
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
|
||||
@@ -601,7 +613,13 @@ function WorkspaceRowInner({
|
||||
isArchiving,
|
||||
dragHandleProps,
|
||||
menuController,
|
||||
archiveLabel,
|
||||
archiveStatus = 'idle',
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyPath,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -617,83 +635,89 @@ function WorkspaceRowInner({
|
||||
onPress()
|
||||
}, [interaction.didLongPressRef, onPress])
|
||||
|
||||
const rowChildren = (
|
||||
<>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{createdAtLabel ? (
|
||||
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
||||
{createdAtLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
const trigger = menuController ? (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
disabled={isArchiving}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
) : (
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
const content = trigger
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer}>
|
||||
{content}
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : createdAtLabel ? (
|
||||
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
||||
{createdAtLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{onArchive ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={styles.kebabButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
<MoreVertical size={14} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={200}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
destructive
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? 'Archive'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowWithMenuContent({
|
||||
function WorkspaceRowWithMenu({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
@@ -713,7 +737,6 @@ function WorkspaceRowWithMenuContent({
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const contextMenu = useContextMenu()
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
@@ -792,72 +815,29 @@ function WorkspaceRowWithMenuContent({
|
||||
})()
|
||||
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={contextMenu}
|
||||
/>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
mobileMode="sheet"
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
||||
>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
pendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||
destructive
|
||||
onSelect={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
>
|
||||
{isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const handleCopyPath = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.workspaceId)
|
||||
toast.copied('Path copied')
|
||||
}, [toast, workspace.workspaceId])
|
||||
|
||||
function WorkspaceRowWithMenu({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<WorkspaceRowWithMenuContent
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1751,6 +1731,27 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
shortcutBadge: {
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
|
||||
@@ -235,22 +235,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
[onNearBottomChange]
|
||||
)
|
||||
|
||||
const scheduleStickToBottom = useCallback((source = 'unknown') => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null
|
||||
if (!followOutputRef.current) {
|
||||
const scheduleStickToBottom = useCallback(
|
||||
(source = 'unknown') => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
scrollMessagesToBottom('auto', source)
|
||||
})
|
||||
}, [scrollMessagesToBottom])
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scrollMessagesToBottom('auto', source)
|
||||
})
|
||||
},
|
||||
[scrollMessagesToBottom]
|
||||
)
|
||||
const forceStickToBottom = useCallback(() => {
|
||||
cancelPendingStickToBottom()
|
||||
scrollMessagesToBottom('auto', 'force')
|
||||
|
||||
@@ -33,6 +33,7 @@ function workspace(
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
diffStat: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SidebarWorkspaceEntry {
|
||||
name: string
|
||||
activityAt: Date | null
|
||||
statusBucket: SidebarStateBucket
|
||||
diffStat: { additions: number; deletions: number } | null
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
@@ -129,6 +130,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
name: workspace.name,
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
}
|
||||
|
||||
project.workspaces.push(row)
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import type { DoctorReport } from "@server/server/doctor/types";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -980,6 +982,9 @@ function HostDetailModal({
|
||||
const activeConnection = runtimeSnapshot?.activeConnection ?? null;
|
||||
const lastError = runtimeSnapshot?.lastError ?? null;
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [doctorReport, setDoctorReport] = useState<DoctorReport | null>(null);
|
||||
const [doctorLoading, setDoctorLoading] = useState(false);
|
||||
const [doctorError, setDoctorError] = useState<string | null>(null);
|
||||
const isHostConnected = useCallback(() => {
|
||||
if (!host) {
|
||||
return false;
|
||||
@@ -1093,6 +1098,30 @@ function HostDetailModal({
|
||||
setDraftLabel(nextValue);
|
||||
}, []);
|
||||
|
||||
const runHealthCheck = useCallback(async () => {
|
||||
const endpoint =
|
||||
activeConnection?.endpoint ??
|
||||
host?.connections.find((c) => c.type === "directTcp")?.endpoint ??
|
||||
null;
|
||||
if (!endpoint) return;
|
||||
|
||||
setDoctorLoading(true);
|
||||
setDoctorError(null);
|
||||
try {
|
||||
const parsed = new URL(buildDaemonWebSocketUrl(endpoint));
|
||||
parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:";
|
||||
const baseUrl = parsed.toString().replace(/\/ws\/?$/, "");
|
||||
const res = await fetch(`${baseUrl}/api/doctor`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const report = (await res.json()) as DoctorReport;
|
||||
setDoctorReport(report);
|
||||
} catch (err) {
|
||||
setDoctorError(err instanceof Error ? err.message : "Health check failed");
|
||||
} finally {
|
||||
setDoctorLoading(false);
|
||||
}
|
||||
}, [activeConnection, host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !host) return;
|
||||
// Initialize once per modal open / host switch; keep user edits fully local while typing.
|
||||
@@ -1103,6 +1132,9 @@ function HostDetailModal({
|
||||
if (!visible) {
|
||||
setIsRestarting(false);
|
||||
setDraftLabel("");
|
||||
setDoctorReport(null);
|
||||
setDoctorLoading(false);
|
||||
setDoctorError(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
@@ -1186,6 +1218,76 @@ function HostDetailModal({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Diagnostics */}
|
||||
{host ? (
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Diagnostics</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => { void runHealthCheck(); }}
|
||||
disabled={doctorLoading || !isConnected}
|
||||
>
|
||||
{doctorLoading ? "Running..." : doctorReport ? "Re-run" : "Run Health Check"}
|
||||
</Button>
|
||||
{doctorError ? (
|
||||
<Text style={{ color: theme.colors.destructive, fontSize: theme.fontSize.xs, marginTop: theme.spacing[2] }}>
|
||||
{doctorError}
|
||||
</Text>
|
||||
) : null}
|
||||
{doctorReport ? (
|
||||
<View style={{ marginTop: theme.spacing[2], gap: theme.spacing[3] }}>
|
||||
{/* Summary */}
|
||||
<Text style={{ fontSize: theme.fontSize.sm }}>
|
||||
{doctorReport.summary.ok > 0 ? (
|
||||
<Text style={{ color: theme.colors.palette.green[400] }}>{doctorReport.summary.ok} passed</Text>
|
||||
) : null}
|
||||
{doctorReport.summary.ok > 0 && (doctorReport.summary.warn > 0 || doctorReport.summary.error > 0) ? " · " : ""}
|
||||
{doctorReport.summary.warn > 0 ? (
|
||||
<Text style={{ color: theme.colors.palette.amber[500] }}>{doctorReport.summary.warn} warning{doctorReport.summary.warn !== 1 ? "s" : ""}</Text>
|
||||
) : null}
|
||||
{doctorReport.summary.warn > 0 && doctorReport.summary.error > 0 ? " · " : ""}
|
||||
{doctorReport.summary.error > 0 ? (
|
||||
<Text style={{ color: theme.colors.destructive }}>{doctorReport.summary.error} error{doctorReport.summary.error !== 1 ? "s" : ""}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{/* Grouped checks */}
|
||||
{(["provider", "config", "runtime"] as const).map((prefix) => {
|
||||
const groupChecks = doctorReport.checks.filter((c) => c.id.startsWith(`${prefix}.`));
|
||||
if (groupChecks.length === 0) return null;
|
||||
const groupLabel = prefix === "provider" ? "Providers" : prefix === "config" ? "Config" : "Runtime";
|
||||
return (
|
||||
<View key={prefix} style={{ gap: theme.spacing[1] }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, textTransform: "uppercase", marginBottom: theme.spacing[1] }}>
|
||||
{groupLabel}
|
||||
</Text>
|
||||
{groupChecks.map((check) => (
|
||||
<View key={check.id} style={{ flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], paddingVertical: 3 }}>
|
||||
<Text style={{
|
||||
fontSize: theme.fontSize.sm,
|
||||
width: 16,
|
||||
color: check.status === "ok"
|
||||
? theme.colors.palette.green[400]
|
||||
: check.status === "warn"
|
||||
? theme.colors.palette.amber[500]
|
||||
: theme.colors.destructive,
|
||||
}}>
|
||||
{check.status === "ok" ? "✓" : check.status === "warn" ? "⚠" : "✗"}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: theme.fontSize.sm, color: theme.colors.foreground }}>{check.label}</Text>
|
||||
<Text style={{ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted }} selectable>{check.detail}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Save/Cancel + Advanced */}
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: theme.colors.border, marginTop: theme.spacing[2], paddingTop: theme.spacing[4] }}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('workspace source of truth consumption', () => {
|
||||
name: 'feat/workspace-sot',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-03-01T00:00:00.000Z'),
|
||||
diffStat: null,
|
||||
}
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace })
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface WorkspaceDescriptor {
|
||||
name: string;
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
activityAt: Date | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -137,6 +138,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
activityAt:
|
||||
activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
name: 'paseo',
|
||||
activityAt: null,
|
||||
statusBucket: 'done',
|
||||
diffStat: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { runSendCommand } from './commands/agent/send.js'
|
||||
import { runInspectCommand } from './commands/agent/inspect.js'
|
||||
import { runWaitCommand } from './commands/agent/wait.js'
|
||||
import { runAttachCommand } from './commands/agent/attach.js'
|
||||
import { runDoctorCommand } from './commands/doctor.js'
|
||||
import { withOutput } from './output/index.js'
|
||||
import { onboardCommand } from './commands/onboard.js'
|
||||
|
||||
@@ -192,6 +193,14 @@ export function createCli(): Command {
|
||||
)
|
||||
.action(withOutput(runDaemonRestartCommand))
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Diagnose your Paseo setup (agents, config, runtime)')
|
||||
.option('--remote', 'Fetch diagnostics from the running daemon instead of checking locally')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host target (used with --remote)')
|
||||
.action(withOutput(runDoctorCommand))
|
||||
|
||||
// Advanced agent commands (less common operations)
|
||||
program.addCommand(createAgentCommand())
|
||||
|
||||
|
||||
97
packages/cli/src/commands/doctor.ts
Normal file
97
packages/cli/src/commands/doctor.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Command } from 'commander'
|
||||
import {
|
||||
runDoctorChecks,
|
||||
type DoctorCheckResult,
|
||||
type DoctorReport,
|
||||
} from '@getpaseo/server'
|
||||
import { getDaemonHost, resolveDaemonTarget } from '../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema } from '../output/index.js'
|
||||
|
||||
interface DoctorRow {
|
||||
check: string
|
||||
status: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
function statusIndicator(status: DoctorCheckResult['status']): string {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
return '✓ ok'
|
||||
case 'warn':
|
||||
return '⚠ warn'
|
||||
case 'error':
|
||||
return '✗ error'
|
||||
}
|
||||
}
|
||||
|
||||
function toDoctorRows(report: DoctorReport): DoctorRow[] {
|
||||
return report.checks.map((c) => ({
|
||||
check: c.label,
|
||||
status: statusIndicator(c.status),
|
||||
detail: c.detail,
|
||||
}))
|
||||
}
|
||||
|
||||
function createDoctorSchema(report: DoctorReport): OutputSchema<DoctorRow> {
|
||||
return {
|
||||
idField: 'check',
|
||||
columns: [
|
||||
{ header: 'CHECK', field: 'check' },
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'status',
|
||||
color: (value) => {
|
||||
const v = typeof value === 'string' ? value : ''
|
||||
if (v.includes('ok')) return 'green'
|
||||
if (v.includes('warn')) return 'yellow'
|
||||
if (v.includes('error')) return 'red'
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
{ header: 'DETAIL', field: 'detail' },
|
||||
],
|
||||
serialize: () => report,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemoteReport(host: string): Promise<DoctorReport> {
|
||||
const target = resolveDaemonTarget(host)
|
||||
const baseUrl =
|
||||
target.type === 'tcp'
|
||||
? target.url.replace(/^ws:\/\//, 'http://').replace(/\/ws$/, '')
|
||||
: null
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('Remote doctor requires a TCP daemon target (not unix socket)')
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/doctor`)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`Doctor endpoint returned ${response.status}: ${text}`)
|
||||
}
|
||||
return (await response.json()) as DoctorReport
|
||||
}
|
||||
|
||||
export type DoctorResult = ListResult<DoctorRow>
|
||||
|
||||
export async function runDoctorCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
): Promise<DoctorResult> {
|
||||
const remote = Boolean(options.remote)
|
||||
|
||||
let report: DoctorReport
|
||||
if (remote) {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
report = await fetchRemoteReport(host)
|
||||
} else {
|
||||
report = await runDoctorChecks()
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
data: toDoctorRows(report),
|
||||
schema: createDoctorSchema(report),
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ use http::Request;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
#[cfg(windows)]
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fast-uri": "^3.1.0",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
@@ -89,11 +90,11 @@
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"sherpa-onnx": "^1.12.23",
|
||||
"sherpa-onnx-node": "^1.12.23",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.14.2",
|
||||
"zod": "^3.23.8",
|
||||
|
||||
@@ -363,14 +363,18 @@ type ClaudeAgentSessionOptions = {
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
function resolveClaudeBinary(): string {
|
||||
function whichClaude(): string | null {
|
||||
try {
|
||||
const claudePath = execSync("which claude", { encoding: "utf8" }).trim();
|
||||
if (claudePath) {
|
||||
return claudePath;
|
||||
}
|
||||
return execSync("which claude", { encoding: "utf8", env: process.env }).trim() || null;
|
||||
} catch {
|
||||
// fall through
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClaudeBinary(): string {
|
||||
const claudePath = whichClaude();
|
||||
if (claudePath) {
|
||||
return claudePath;
|
||||
}
|
||||
throw new Error(
|
||||
"Claude CLI not found. Install claude or configure agents.providers.claude.command.mode='replace'."
|
||||
@@ -1410,10 +1414,16 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.defaults = options.defaults;
|
||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
try {
|
||||
this.claudePath = execSync("which claude", { encoding: "utf8" }).trim() || null;
|
||||
} catch {
|
||||
this.claudePath = null;
|
||||
this.claudePath = whichClaude();
|
||||
if (this.claudePath) {
|
||||
try {
|
||||
const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim();
|
||||
this.logger.info({ claudePath: this.claudePath, version }, "Resolved Claude binary");
|
||||
} catch {
|
||||
this.logger.info({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)");
|
||||
}
|
||||
} else {
|
||||
this.logger.warn("Claude binary not found in PATH; SDK will use bundled binary");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||
import { getOrCreateServerId } from "./server-id.js";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import { runDoctorChecks } from "./doctor/index.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentProvider,
|
||||
@@ -248,6 +249,20 @@ export async function createPaseoDaemon(
|
||||
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Doctor diagnostic endpoint
|
||||
app.get("/api/doctor", async (_req, res) => {
|
||||
try {
|
||||
const report = await runDoctorChecks({
|
||||
paseoHome: config.paseoHome,
|
||||
version: daemonVersion,
|
||||
});
|
||||
res.json(report);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/files/download", async (req, res) => {
|
||||
const token =
|
||||
typeof req.query.token === "string" && req.query.token.trim().length > 0
|
||||
|
||||
77
packages/server/src/server/doctor/checks/config-checks.ts
Normal file
77
packages/server/src/server/doctor/checks/config-checks.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { loadPersistedConfig, type PersistedConfig } from "../../persisted-config.js";
|
||||
import type { DoctorCheckResult } from "../types.js";
|
||||
|
||||
/**
|
||||
* Validate that a listen string is parseable as a valid listen target.
|
||||
* Inline check to avoid importing from bootstrap.ts (which has heavy transitive deps).
|
||||
*/
|
||||
function isValidListenString(listen: string): boolean {
|
||||
// Named pipe
|
||||
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) return true;
|
||||
// Unix socket
|
||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) return true;
|
||||
if (listen.startsWith("unix://")) return true;
|
||||
// TCP host:port
|
||||
if (listen.includes(":")) {
|
||||
const port = parseInt(listen.split(":")[1]!, 10);
|
||||
return Number.isFinite(port);
|
||||
}
|
||||
// Just a port
|
||||
return Number.isFinite(parseInt(listen, 10));
|
||||
}
|
||||
|
||||
function checkConfigValid(config: PersistedConfig | null, loadError: string | null): DoctorCheckResult {
|
||||
if (config) {
|
||||
return {
|
||||
id: "config.valid",
|
||||
label: "Config file",
|
||||
status: "ok",
|
||||
detail: "Valid",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "config.valid",
|
||||
label: "Config file",
|
||||
status: "error",
|
||||
detail: loadError ?? "Unknown error",
|
||||
};
|
||||
}
|
||||
|
||||
function checkListenAddress(config: PersistedConfig | null): DoctorCheckResult {
|
||||
if (!config) {
|
||||
return {
|
||||
id: "config.listen",
|
||||
label: "Listen address",
|
||||
status: "error",
|
||||
detail: "Cannot check (config failed to load)",
|
||||
};
|
||||
}
|
||||
|
||||
const listen = config.daemon?.listen ?? "127.0.0.1:6767";
|
||||
if (!isValidListenString(listen)) {
|
||||
return {
|
||||
id: "config.listen",
|
||||
label: "Listen address",
|
||||
status: "error",
|
||||
detail: `Malformed listen address: ${listen}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "config.listen",
|
||||
label: "Listen address",
|
||||
status: "ok",
|
||||
detail: listen,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runConfigChecks(paseoHome: string): Promise<DoctorCheckResult[]> {
|
||||
let config: PersistedConfig | null = null;
|
||||
let loadError: string | null = null;
|
||||
try {
|
||||
config = loadPersistedConfig(paseoHome);
|
||||
} catch (err) {
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
return [checkConfigValid(config, loadError), checkListenAddress(config)];
|
||||
}
|
||||
94
packages/server/src/server/doctor/checks/provider-checks.ts
Normal file
94
packages/server/src/server/doctor/checks/provider-checks.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { DoctorCheckResult } from "../types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface ProviderDef {
|
||||
name: string;
|
||||
command: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderDef[] = [
|
||||
{ name: "claude", command: "claude", label: "Claude CLI" },
|
||||
{ name: "codex", command: "codex", label: "Codex CLI" },
|
||||
{ name: "opencode", command: "opencode", label: "OpenCode CLI" },
|
||||
];
|
||||
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
|
||||
async function whichCommand(command: string): Promise<string | null> {
|
||||
const whichBin = process.platform === "win32" ? "where" : "which";
|
||||
try {
|
||||
const { stdout } = await execFileAsync(whichBin, [command], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS });
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getVersion(binaryPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(binaryPath, ["--version"], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS });
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkBinary(provider: ProviderDef, binaryPath: string | null): DoctorCheckResult {
|
||||
if (binaryPath) {
|
||||
return {
|
||||
id: `provider.${provider.name}.binary`,
|
||||
label: provider.label,
|
||||
status: "ok",
|
||||
detail: binaryPath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: `provider.${provider.name}.binary`,
|
||||
label: provider.label,
|
||||
status: "error",
|
||||
detail: "Not found in PATH",
|
||||
};
|
||||
}
|
||||
|
||||
async function checkVersion(provider: ProviderDef, binaryPath: string | null): Promise<DoctorCheckResult> {
|
||||
if (!binaryPath) {
|
||||
return {
|
||||
id: `provider.${provider.name}.version`,
|
||||
label: `${provider.label} version`,
|
||||
status: "error",
|
||||
detail: "Binary not found",
|
||||
};
|
||||
}
|
||||
|
||||
const version = await getVersion(binaryPath);
|
||||
if (version) {
|
||||
return {
|
||||
id: `provider.${provider.name}.version`,
|
||||
label: `${provider.label} version`,
|
||||
status: "ok",
|
||||
detail: version,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `provider.${provider.name}.version`,
|
||||
label: `${provider.label} version`,
|
||||
status: "warn",
|
||||
detail: "Installed but version could not be parsed",
|
||||
};
|
||||
}
|
||||
|
||||
async function checkProvider(provider: ProviderDef): Promise<DoctorCheckResult[]> {
|
||||
const binaryPath = await whichCommand(provider.command);
|
||||
return [checkBinary(provider, binaryPath), await checkVersion(provider, binaryPath)];
|
||||
}
|
||||
|
||||
export async function runProviderChecks(): Promise<DoctorCheckResult[]> {
|
||||
const perProvider = await Promise.all(PROVIDERS.map(checkProvider));
|
||||
return perProvider.flat();
|
||||
}
|
||||
43
packages/server/src/server/doctor/checks/runtime-checks.ts
Normal file
43
packages/server/src/server/doctor/checks/runtime-checks.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { resolveDaemonVersion } from "../../daemon-version.js";
|
||||
import type { DoctorCheckResult } from "../types.js";
|
||||
|
||||
function checkNodeVersion(): DoctorCheckResult {
|
||||
return {
|
||||
id: "runtime.node",
|
||||
label: "Node.js",
|
||||
status: "ok",
|
||||
detail: process.version,
|
||||
};
|
||||
}
|
||||
|
||||
function checkPaseoVersion(version?: string): DoctorCheckResult {
|
||||
const resolved = version ?? tryResolveDaemonVersion();
|
||||
if (resolved) {
|
||||
return {
|
||||
id: "runtime.paseo",
|
||||
label: "Paseo daemon",
|
||||
status: "ok",
|
||||
detail: resolved,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "runtime.paseo",
|
||||
label: "Paseo daemon",
|
||||
status: "error",
|
||||
detail: "Version unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function tryResolveDaemonVersion(): string | null {
|
||||
try {
|
||||
return resolveDaemonVersion();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRuntimeChecks(options?: {
|
||||
version?: string;
|
||||
}): Promise<DoctorCheckResult[]> {
|
||||
return [checkNodeVersion(), checkPaseoVersion(options?.version)];
|
||||
}
|
||||
2
packages/server/src/server/doctor/index.ts
Normal file
2
packages/server/src/server/doctor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { runDoctorChecks } from "./run-doctor-checks.js";
|
||||
export type { CheckStatus, DoctorCheckResult, DoctorReport } from "./types.js";
|
||||
86
packages/server/src/server/doctor/run-doctor-checks.test.ts
Normal file
86
packages/server/src/server/doctor/run-doctor-checks.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { runDoctorChecks } from "./run-doctor-checks.js";
|
||||
import type { DoctorReport, DoctorCheckResult } from "./types.js";
|
||||
|
||||
describe("runDoctorChecks", () => {
|
||||
it("returns a valid DoctorReport shape", async () => {
|
||||
const report = await runDoctorChecks();
|
||||
|
||||
expect(report).toHaveProperty("checks");
|
||||
expect(report).toHaveProperty("summary");
|
||||
expect(report).toHaveProperty("timestamp");
|
||||
expect(Array.isArray(report.checks)).toBe(true);
|
||||
});
|
||||
|
||||
it("has summary counts matching checks array", async () => {
|
||||
const report = await runDoctorChecks();
|
||||
|
||||
const okCount = report.checks.filter((c) => c.status === "ok").length;
|
||||
const warnCount = report.checks.filter((c) => c.status === "warn").length;
|
||||
const errorCount = report.checks.filter((c) => c.status === "error").length;
|
||||
|
||||
expect(report.summary.ok).toBe(okCount);
|
||||
expect(report.summary.warn).toBe(warnCount);
|
||||
expect(report.summary.error).toBe(errorCount);
|
||||
expect(okCount + warnCount + errorCount).toBe(report.checks.length);
|
||||
});
|
||||
|
||||
it("has a valid ISO timestamp", async () => {
|
||||
const report = await runDoctorChecks();
|
||||
const parsed = new Date(report.timestamp);
|
||||
expect(parsed.toISOString()).toBe(report.timestamp);
|
||||
});
|
||||
|
||||
it("each check has the expected shape", async () => {
|
||||
const report = await runDoctorChecks();
|
||||
|
||||
for (const check of report.checks) {
|
||||
expect(typeof check.id).toBe("string");
|
||||
expect(check.id.length).toBeGreaterThan(0);
|
||||
expect(typeof check.label).toBe("string");
|
||||
expect(check.label.length).toBeGreaterThan(0);
|
||||
expect(["ok", "warn", "error"]).toContain(check.status);
|
||||
expect(typeof check.detail).toBe("string");
|
||||
expect(check.detail.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes expected check IDs", async () => {
|
||||
const report = await runDoctorChecks();
|
||||
const ids = report.checks.map((c) => c.id);
|
||||
|
||||
// Provider checks
|
||||
expect(ids).toContain("provider.claude.binary");
|
||||
expect(ids).toContain("provider.claude.version");
|
||||
expect(ids).toContain("provider.codex.binary");
|
||||
expect(ids).toContain("provider.codex.version");
|
||||
expect(ids).toContain("provider.opencode.binary");
|
||||
expect(ids).toContain("provider.opencode.version");
|
||||
|
||||
// Config checks
|
||||
expect(ids).toContain("config.valid");
|
||||
expect(ids).toContain("config.listen");
|
||||
|
||||
// Runtime checks
|
||||
expect(ids).toContain("runtime.node");
|
||||
expect(ids).toContain("runtime.paseo");
|
||||
});
|
||||
|
||||
it("runtime.node reports the current Node version", async () => {
|
||||
const report = await runDoctorChecks();
|
||||
const nodeCheck = report.checks.find((c) => c.id === "runtime.node");
|
||||
|
||||
expect(nodeCheck).toBeDefined();
|
||||
expect(nodeCheck!.status).toBe("ok");
|
||||
expect(nodeCheck!.detail).toBe(process.version);
|
||||
});
|
||||
|
||||
it("accepts a custom version option", async () => {
|
||||
const report = await runDoctorChecks({ version: "1.2.3-test" });
|
||||
const paseoCheck = report.checks.find((c) => c.id === "runtime.paseo");
|
||||
|
||||
expect(paseoCheck).toBeDefined();
|
||||
expect(paseoCheck!.status).toBe("ok");
|
||||
expect(paseoCheck!.detail).toBe("1.2.3-test");
|
||||
});
|
||||
});
|
||||
26
packages/server/src/server/doctor/run-doctor-checks.ts
Normal file
26
packages/server/src/server/doctor/run-doctor-checks.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { resolvePaseoHome } from "../paseo-home.js";
|
||||
import { runProviderChecks } from "./checks/provider-checks.js";
|
||||
import { runConfigChecks } from "./checks/config-checks.js";
|
||||
import { runRuntimeChecks } from "./checks/runtime-checks.js";
|
||||
import type { DoctorReport } from "./types.js";
|
||||
|
||||
export async function runDoctorChecks(options?: {
|
||||
paseoHome?: string;
|
||||
version?: string;
|
||||
}): Promise<DoctorReport> {
|
||||
const paseoHome = options?.paseoHome ?? resolvePaseoHome();
|
||||
|
||||
const checks = [
|
||||
...(await runProviderChecks()),
|
||||
...(await runConfigChecks(paseoHome)),
|
||||
...(await runRuntimeChecks({ version: options?.version })),
|
||||
];
|
||||
|
||||
const summary = {
|
||||
ok: checks.filter((c) => c.status === "ok").length,
|
||||
warn: checks.filter((c) => c.status === "warn").length,
|
||||
error: checks.filter((c) => c.status === "error").length,
|
||||
};
|
||||
|
||||
return { checks, summary, timestamp: new Date().toISOString() };
|
||||
}
|
||||
14
packages/server/src/server/doctor/types.ts
Normal file
14
packages/server/src/server/doctor/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type CheckStatus = "ok" | "warn" | "error";
|
||||
|
||||
export interface DoctorCheckResult {
|
||||
id: string;
|
||||
label: string;
|
||||
status: CheckStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DoctorReport {
|
||||
checks: DoctorCheckResult[];
|
||||
summary: { ok: number; warn: number; error: number };
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -23,6 +23,14 @@ export {
|
||||
type SherpaLoaderEnvResolution,
|
||||
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
// Doctor health check
|
||||
export {
|
||||
runDoctorChecks,
|
||||
type CheckStatus,
|
||||
type DoctorCheckResult,
|
||||
type DoctorReport,
|
||||
} from "./doctor/index.js";
|
||||
|
||||
// Agent SDK types for CLI commands
|
||||
export type {
|
||||
AgentMode,
|
||||
|
||||
@@ -137,6 +137,7 @@ import {
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-bootstrap.js'
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
getCheckoutStatus,
|
||||
listBranchSuggestions,
|
||||
NotGitRepoError,
|
||||
@@ -5253,6 +5254,13 @@ export class Session {
|
||||
// Fall back to the persisted label if checkout metadata is unavailable.
|
||||
}
|
||||
|
||||
let diffStat: { additions: number; deletions: number } | null = null
|
||||
try {
|
||||
diffStat = await getCheckoutShortstat(workspace.cwd)
|
||||
} catch {
|
||||
// Non-critical — leave null on failure.
|
||||
}
|
||||
|
||||
return {
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
@@ -5263,6 +5271,7 @@ export class Session {
|
||||
name: displayName,
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1490,6 +1490,10 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
name: z.string(),
|
||||
status: WorkspaceStateBucketSchema,
|
||||
activityAt: z.string().nullable(),
|
||||
diffStat: z.object({
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
}).nullable().optional(),
|
||||
})
|
||||
|
||||
export const AgentUpdateMessageSchema = z.object({
|
||||
|
||||
@@ -1087,6 +1087,77 @@ export async function getCheckoutStatusLite(
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckoutShortstat {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export async function getCheckoutShortstat(
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<CheckoutShortstat | null> {
|
||||
try {
|
||||
await requireGitRepo(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||
if (!baseRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (currentBranch === baseRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mergeBase: string;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base HEAD ${baseRef}`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
mergeBase = stdout.trim();
|
||||
if (!mergeBase) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff --shortstat ${mergeBase}`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const text = stdout.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
const addMatch = text.match(/(\d+)\s+insertion/);
|
||||
if (addMatch) {
|
||||
additions = Number.parseInt(addMatch[1]!, 10);
|
||||
}
|
||||
const delMatch = text.match(/(\d+)\s+deletion/);
|
||||
if (delMatch) {
|
||||
deletions = Number.parseInt(delMatch[1]!, 10);
|
||||
}
|
||||
|
||||
if (additions === 0 && deletions === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
|
||||
@@ -25,12 +25,9 @@ function Home() {
|
||||
return (
|
||||
<CursorFieldProvider>
|
||||
{/* Hero section with background image */}
|
||||
<div
|
||||
className="relative bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: 'url(/hero-bg.jpg)' }}
|
||||
>
|
||||
<div className="relative bg-cover bg-center bg-no-repeat">
|
||||
<div className="absolute inset-0 bg-background/90" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-64 bg-gradient-to-t from-black to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-64 bg-linear-to-t from-black to-transparent" />
|
||||
|
||||
<div className="relative p-6 pb-10 md:px-20 md:pt-20 md:pb-12 max-w-3xl mx-auto">
|
||||
<Nav />
|
||||
@@ -117,23 +114,17 @@ function Nav() {
|
||||
function Hero() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl md:text-5xl font-medium tracking-tight">
|
||||
Orchestrate coding agents from anywhere
|
||||
<h1 className="text-3xl md:text-5xl font-bold tracking-tight">
|
||||
One interface for all your coding agents
|
||||
</h1>
|
||||
<p className="text-white/70 text-lg leading-relaxed">
|
||||
Run Claude Code, Codex, and OpenCode. From your phone, desktop and CLI, with voice support built-in.
|
||||
Run Claude Code, Codex, and OpenCode on your machine. Connect from your phone or desktop.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Differentiator({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
function Differentiator({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="font-medium text-sm">{title}</p>
|
||||
@@ -183,13 +174,7 @@ function Features() {
|
||||
)
|
||||
}
|
||||
|
||||
function Feature({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
function Feature({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-base">{title}</p>
|
||||
@@ -226,17 +211,13 @@ function GetStarted() {
|
||||
<GlobeIcon className="h-4 w-4" />
|
||||
Launch Web App
|
||||
</a>
|
||||
<span
|
||||
className="relative group inline-flex items-center justify-center rounded-lg border border-white/10 px-3 py-2 text-white/40 cursor-default"
|
||||
>
|
||||
<span className="relative group inline-flex items-center justify-center rounded-lg border border-white/10 px-3 py-2 text-white/40 cursor-default">
|
||||
<AppleIcon className="h-5 w-5" />
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 rounded bg-white text-black text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
Coming soon
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="relative group inline-flex items-center justify-center rounded-lg border border-white/10 px-3 py-2 text-white/40 cursor-default"
|
||||
>
|
||||
<span className="relative group inline-flex items-center justify-center rounded-lg border border-white/10 px-3 py-2 text-white/40 cursor-default">
|
||||
<GooglePlayIcon className="h-5 w-5 opacity-40" />
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 rounded bg-white text-black text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
Coming soon
|
||||
@@ -286,25 +267,38 @@ function AppStoreIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
{...props}
|
||||
>
|
||||
<path d="M342.277 86.6927C463.326 84.6952 587.87 65.619 705.523 104.97C830.467 143.522 874.012 278.153 872.814 397.105C873.713 481.299 874.012 566.193 858.931 649.19C834.262 804.895 746.172 873.01 590.666 874.608C422.377 880.301 172.489 908.965 104.474 711.012C76.5092 599.452 86.6964 481.1 88.1946 366.843C98.9811 200.75 163.301 90.2882 342.277 86.6927ZM715.411 596.156C758.856 591.362 754.362 524.645 710.816 524.545C610.542 525.244 639.605 550.513 594.462 456.83C577.383 418.778 540.529 337.279 496.085 396.006C479.206 431.062 516.359 464.121 528.844 495.382C569.892 560.6 606.647 628.515 648.494 693.334C667.77 724.495 716.509 696.73 697.333 663.372C685.048 642.298 677.258 619.726 665.773 598.253C682.452 597.854 698.831 598.053 715.411 596.156Z" />
|
||||
<path d="M697.234 663.371C716.41 696.729 667.671 724.494 648.395 693.333C606.548 628.614 569.794 560.699 528.745 495.381C516.161 464.219 479.107 431.161 495.986 396.005C540.43 337.178 577.384 418.776 594.363 456.829C639.506 550.512 610.443 525.243 710.717 524.544C754.263 524.644 758.757 591.361 715.312 596.155C698.732 598.052 682.453 597.852 665.674 598.252C677.159 619.725 684.95 642.297 697.234 663.371Z" fill="black" />
|
||||
<path d="M474.312 257.679C486.597 230.913 517.059 198.453 545.224 224.92C564.3 242.298 551.316 269.465 538.332 287.242C489.194 363.747 450.242 445.844 405.598 524.845C445.448 528.341 485.598 525.844 525.149 532.835C564.1 539.827 558.907 597.455 519.256 598.353C442.153 601.35 365.049 595.457 287.845 599.652C260.28 597.554 225.024 612.336 203.751 589.065C161.104 516.456 275.761 527.442 317.608 524.546C343.776 499.377 356.659 456.93 377.833 425.769C395.311 394.608 412.39 363.147 429.868 331.986C432.964 322.199 418.982 314.109 415.486 305.12C349.169 230.713 442.153 172.885 474.312 257.679Z" fill="black" />
|
||||
<path d="M265.471 626.12C284.647 595.758 329.491 609.042 330.39 643.199C325.296 664.872 313.511 684.647 298.53 701.027C275.758 724.997 235.009 703.124 242.5 670.864C246.195 654.485 256.882 640.302 265.471 626.12Z" fill="black" />
|
||||
<path
|
||||
d="M697.234 663.371C716.41 696.729 667.671 724.494 648.395 693.333C606.548 628.614 569.794 560.699 528.745 495.381C516.161 464.219 479.107 431.161 495.986 396.005C540.43 337.178 577.384 418.776 594.363 456.829C639.506 550.512 610.443 525.243 710.717 524.544C754.263 524.644 758.757 591.361 715.312 596.155C698.732 598.052 682.453 597.852 665.674 598.252C677.159 619.725 684.95 642.297 697.234 663.371Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M474.312 257.679C486.597 230.913 517.059 198.453 545.224 224.92C564.3 242.298 551.316 269.465 538.332 287.242C489.194 363.747 450.242 445.844 405.598 524.845C445.448 528.341 485.598 525.844 525.149 532.835C564.1 539.827 558.907 597.455 519.256 598.353C442.153 601.35 365.049 595.457 287.845 599.652C260.28 597.554 225.024 612.336 203.751 589.065C161.104 516.456 275.761 527.442 317.608 524.546C343.776 499.377 356.659 456.93 377.833 425.769C395.311 394.608 412.39 363.147 429.868 331.986C432.964 322.199 418.982 314.109 415.486 305.12C349.169 230.713 442.153 172.885 474.312 257.679Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M265.471 626.12C284.647 595.758 329.491 609.042 330.39 643.199C325.296 664.872 313.511 684.647 298.53 701.027C275.758 724.997 235.009 703.124 242.5 670.864C246.195 654.485 256.882 640.302 265.471 626.12Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GooglePlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 28.99 31.99"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28.99 31.99" aria-hidden="true" {...props}>
|
||||
<path d="M13.54 15.28.12 29.34a3.66 3.66 0 0 0 5.33 2.16l15.1-8.6Z" fill="#ea4335" />
|
||||
<path d="m27.11 12.89-6.53-3.74-7.35 6.45 7.38 7.28 6.48-3.7a3.54 3.54 0 0 0 1.5-4.79 3.62 3.62 0 0 0-1.5-1.5z" fill="#fbbc04" />
|
||||
<path d="M.12 2.66a3.57 3.57 0 0 0-.12.92v24.84a3.57 3.57 0 0 0 .12.92L14 15.64Z" fill="#4285f4" />
|
||||
<path d="m13.64 16 6.94-6.85L5.5.51A3.73 3.73 0 0 0 3.63 0 3.64 3.64 0 0 0 .12 2.65Z" fill="#34a853" />
|
||||
<path
|
||||
d="m27.11 12.89-6.53-3.74-7.35 6.45 7.38 7.28 6.48-3.7a3.54 3.54 0 0 0 1.5-4.79 3.62 3.62 0 0 0-1.5-1.5z"
|
||||
fill="#fbbc04"
|
||||
/>
|
||||
<path
|
||||
d="M.12 2.66a3.57 3.57 0 0 0-.12.92v24.84a3.57 3.57 0 0 0 .12.92L14 15.64Z"
|
||||
fill="#4285f4"
|
||||
/>
|
||||
<path
|
||||
d="m13.64 16 6.94-6.85L5.5.51A3.73 3.73 0 0 0 3.63 0 3.64 3.64 0 0 0 .12 2.65Z"
|
||||
fill="#34a853"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -330,13 +324,7 @@ function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
function Step({
|
||||
number,
|
||||
children,
|
||||
}: {
|
||||
number: number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
function Step({ number, children }: { number: number; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-medium">
|
||||
@@ -400,22 +388,19 @@ function Story() {
|
||||
<h2 className="text-2xl font-medium">Background</h2>
|
||||
<div className="space-y-4 text-sm text-white/60">
|
||||
<p>
|
||||
I started using Claude Code soon after it launched, often on my phone
|
||||
while going on walks to spend less time at my desk. I'd SSH into Tmux
|
||||
from my phone. It worked, but the UX was rough. Dictation was bad, the
|
||||
virtual keyboard was awkward, and the TUI would randomly start
|
||||
flickering, which forced me to start over very often.
|
||||
I started using Claude Code soon after it launched, often on my phone while going on walks
|
||||
to spend less time at my desk. I'd SSH into Tmux from my phone. It worked, but the UX was
|
||||
rough. Dictation was bad, the virtual keyboard was awkward, and the TUI would randomly
|
||||
start flickering, which forced me to start over very often.
|
||||
</p>
|
||||
<p>
|
||||
I started building a simple app to manage agents via voice. I continued
|
||||
adding features as I needed them, and it slowly turned into what Paseo
|
||||
is today.
|
||||
I started building a simple app to manage agents via voice. I continued adding features as
|
||||
I needed them, and it slowly turned into what Paseo is today.
|
||||
</p>
|
||||
<p>
|
||||
Anthropic and OpenAI added coding agents to their mobile apps since I
|
||||
started working on this, but they force you into cloud sandboxes where
|
||||
you lose your whole setup. I also like testing different agents, so
|
||||
locking myself to a single harness or model wasn't an option.
|
||||
Anthropic and OpenAI added coding agents to their mobile apps since I started working on
|
||||
this, but they force you into cloud sandboxes where you lose your whole setup. I also like
|
||||
testing different agents, so locking myself to a single harness or model wasn't an option.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,55 +413,39 @@ function FAQ() {
|
||||
<h2 className="text-2xl font-medium">FAQ</h2>
|
||||
<div className="space-y-6">
|
||||
<FAQItem question="Is this free?">
|
||||
Paseo is free and open source. It wraps CLI tools like Claude Code and
|
||||
Codex, which you'll need to have installed and configured with your
|
||||
own credentials. Voice is local-first by default and can optionally use
|
||||
OpenAI speech providers if you configure them.
|
||||
Paseo is free and open source. It wraps CLI tools like Claude Code and Codex, which you'll
|
||||
need to have installed and configured with your own credentials. Voice is local-first by
|
||||
default and can optionally use OpenAI speech providers if you configure them.
|
||||
</FAQItem>
|
||||
<FAQItem question="Does my code leave my machine?">
|
||||
Paseo itself doesn't send your code anywhere. Agents run locally and
|
||||
communicate with their own APIs as they normally would. We provide an
|
||||
optional end-to-end encrypted relay for remote access, but you can
|
||||
also connect directly over your local network or use your own tunnel.
|
||||
</FAQItem>
|
||||
<FAQItem question="What agents does it support?">
|
||||
Claude Code, Codex, and OpenCode.
|
||||
Paseo itself doesn't send your code anywhere. Agents run locally and communicate with
|
||||
their own APIs as they normally would. We provide an optional end-to-end encrypted relay
|
||||
for remote access, but you can also connect directly over your local network or use your
|
||||
own tunnel.
|
||||
</FAQItem>
|
||||
<FAQItem question="What agents does it support?">Claude Code, Codex, and OpenCode.</FAQItem>
|
||||
<FAQItem question="What's the business model?">There isn't one.</FAQItem>
|
||||
<FAQItem question="Isn't this just more screen time?">
|
||||
I won't pretend this can't be misused to squeeze every minute of your
|
||||
day into work. But for me it means less time at my desk, not more. I
|
||||
brainstorm whole features with voice. I kick off work at my desk, then
|
||||
check in from my phone during a walk. I see what an agent needs, send
|
||||
a voice reply, and put my phone away.
|
||||
</FAQItem>
|
||||
<FAQItem question="What does Paseo mean?">
|
||||
Stroll, in Spanish. 🚶♂️
|
||||
I won't pretend this can't be misused to squeeze every minute of your day into work. But
|
||||
for me it means less time at my desk, not more. I brainstorm whole features with voice. I
|
||||
kick off work at my desk, then check in from my phone during a walk. I see what an agent
|
||||
needs, send a voice reply, and put my phone away.
|
||||
</FAQItem>
|
||||
<FAQItem question="What does Paseo mean?">Stroll, in Spanish. 🚶♂️</FAQItem>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FAQItem({
|
||||
question,
|
||||
children,
|
||||
}: {
|
||||
question: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
function FAQItem({ question, children }: { question: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<details className="group">
|
||||
<summary className="font-medium text-sm cursor-pointer list-none flex items-start gap-2">
|
||||
<span className="font-mono text-white/40 group-open:hidden">+</span>
|
||||
<span className="font-mono text-white/40 hidden group-open:inline">
|
||||
-
|
||||
</span>
|
||||
<span className="font-mono text-white/40 hidden group-open:inline">-</span>
|
||||
{question}
|
||||
</summary>
|
||||
<div className="text-sm text-white/60 space-y-2 mt-2 ml-4">
|
||||
{children}
|
||||
</div>
|
||||
<div className="text-sm text-white/60 space-y-2 mt-2 ml-4">{children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user