diff --git a/01-main-screen.png b/01-main-screen.png deleted file mode 100644 index bb0934794..000000000 Binary files a/01-main-screen.png and /dev/null differ diff --git a/02-agent-screen.png b/02-agent-screen.png deleted file mode 100644 index cfd40908d..000000000 Binary files a/02-agent-screen.png and /dev/null differ diff --git a/02-sidebar-open.png b/02-sidebar-open.png deleted file mode 100644 index 67c9ee751..000000000 Binary files a/02-sidebar-open.png and /dev/null differ diff --git a/03-after-slash.png b/03-after-slash.png deleted file mode 100644 index 2c7d23476..000000000 Binary files a/03-after-slash.png and /dev/null differ diff --git a/03-agent-screen.png b/03-agent-screen.png deleted file mode 100644 index a45c39d11..000000000 Binary files a/03-agent-screen.png and /dev/null differ diff --git a/04-after-slash.png b/04-after-slash.png deleted file mode 100644 index f28ca7e44..000000000 Binary files a/04-after-slash.png and /dev/null differ diff --git a/05-autocomplete-visible.png b/05-autocomplete-visible.png deleted file mode 100644 index 596686f0e..000000000 Binary files a/05-autocomplete-visible.png and /dev/null differ diff --git a/PRODUCTION.md b/PRODUCTION.md deleted file mode 100644 index 847df2e64..000000000 --- a/PRODUCTION.md +++ /dev/null @@ -1,313 +0,0 @@ -# Production Architecture - -## Overview - -Paseo ships as a headless daemon distributed via npm, with native apps (iOS/Android) and a bundled web UI. All communication is end-to-end encrypted. By default, the daemon connects to Paseo Link for remote access. - -## Distribution - -```bash -npm install -g @paseo/daemon -paseo start -``` - -The daemon package includes the web UI bundle, served at `http://localhost:6767`. - -### Package Structure - -``` -@paseo/daemon -├── dist/ -│ ├── cli.js # Entry point -│ ├── server/ # Daemon code -│ └── public/ # Bundled web UI from @paseo/app -``` - -### CLI Commands - -```bash -paseo start # Run in foreground, connect to Link -paseo start --daemon # Run in background -paseo start --no-link # Direct connections only (for VPN/Tailscale users) -paseo stop # Stop background daemon -paseo pair # Generate pairing code -paseo devices # List paired devices -paseo revoke # Remove a paired device -``` - -## Security Model - -### Threat Model - -Pairing protects against: -- Malicious JS on websites trying to connect to localhost -- Network attackers (misconfigured firewall, shared network) -- Link server snooping (E2EE - it only sees encrypted bytes) -- Unauthorized local network users - -Not protected (out of scope): -- Malicious processes running as your user (already have full access) - -### Device Pairing - -Every device must pair with the daemon before communicating. Pairing is a one-time event per device. - -**Flow:** - -1. User runs `paseo pair` -2. Daemon generates one-time token (e.g., `K3X9-M2B7`), held in memory -3. Token displayed as QR code + plaintext -4. Client connects and presents token -5. ECDH key exchange → shared secret derived -6. Token invalidated, device stored as paired -7. All future communication encrypted with derived key - -**Token format:** -- 8 alphanumeric characters (no ambiguous chars: 0/O, 1/l/I) -- Expires after 5 minutes or single use -- Example: `K3X9-M2B7` - -**Stored per device:** - -```typescript -interface PairedDevice { - id: string - name: string // "Mohamed's iPhone" - publicKey: string - pairedAt: Date - lastSeen: Date -} -``` - -### End-to-End Encryption - -All communication is E2EE, regardless of transport: - -``` -┌─────────────────────────────────────────────────┐ -│ Transport │ -│ (direct WS / Link / Tailscale / LAN) │ -└─────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ E2EE Layer (always on) │ -│ Pairing token → key exchange │ -└─────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ Application Protocol │ -└─────────────────────────────────────────────────┘ -``` - -## Connectivity - -### Paseo Link (default) - -Remote access via `link.paseo.sh`: - -``` -Phone ──WS──▶ Cloudflare Edge ──▶ Durable Object (daemon-xyz) - ▲ -Daemon ──WS──▶ Cloudflare Edge ──────────┘ -``` - -**Link properties:** -- Dumb pipe - forwards encrypted bytes only -- Cannot read traffic (E2EE) -- Stateful via Cloudflare Durable Objects -- Both daemon and clients routed to same DO instance by daemon ID -- Enabled by default, disable with `--no-link` - -### Direct Access - -For local network or VPN (Tailscale, etc.): - -- Client connects directly to daemon WebSocket -- E2EE still required (paired device presents public key) -- No Link involved -- Use `paseo start --no-link` if you only want direct connections - -**Pairing URL format:** - -``` -paseo://@?token= -``` - -## Build & Bundling - -### Web UI Bundling - -The web UI is built from `@paseo/app` and copied into the daemon's dist: - -```json -{ - "scripts": { - "build:app": "npm run build --workspace=@paseo/app -- --platform web", - "build:server": "tsc && cp -r ../app/dist/web ./dist/public", - "build": "npm run build:app && npm run build:server" - } -} -``` - -### Server Static Serving - -```typescript -import express from 'express' -import path from 'path' - -const app = express() - -// API routes -app.use('/api', apiRouter) - -// Static files - bundled web app -app.use(express.static(path.join(__dirname, 'public'))) - -// SPA fallback -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')) -}) -``` - -## Daemon Process Management - -### Background Mode - -```typescript -import { spawn } from 'child_process' -import fs from 'fs' - -if (command === 'start' && process.argv.includes('--daemon')) { - const child = spawn(process.execPath, [__filename, 'start'], { - detached: true, - stdio: 'ignore', - }) - child.unref() - fs.writeFileSync('~/.paseo/daemon.pid', child.pid.toString()) - console.log(`Daemon started (pid: ${child.pid})`) - process.exit(0) -} - -if (command === 'stop') { - const pid = fs.readFileSync('~/.paseo/daemon.pid', 'utf-8') - process.kill(parseInt(pid)) - fs.unlinkSync('~/.paseo/daemon.pid') - console.log('Daemon stopped') -} -``` - -### System Service (optional) - -For auto-start on boot, users can install a system service: - -**macOS (launchd):** - -```xml - - - - - Label - com.paseo.daemon - ProgramArguments - - /usr/local/bin/paseo - start - - RunAtLoad - - KeepAlive - - - -``` - -**Linux (systemd):** - -```ini -[Unit] -Description=Paseo Daemon -After=network.target - -[Service] -ExecStart=/usr/local/bin/paseo start -Restart=always -User=%u - -[Install] -WantedBy=default.target -``` - -## Paseo Link Server (Cloudflare) - -### Durable Object Implementation - -```typescript -export class LinkRoom extends DurableObject { - daemon: WebSocket | null = null - clients: Map = new Map() - - async fetch(req: Request) { - const upgradeHeader = req.headers.get('Upgrade') - if (upgradeHeader !== 'websocket') { - return new Response('Expected WebSocket', { status: 426 }) - } - - const [client, server] = Object.values(new WebSocketPair()) - const role = req.headers.get('x-paseo-role') - const clientId = req.headers.get('x-paseo-client-id') - - server.accept() - - if (role === 'daemon') { - this.daemon = server - server.addEventListener('message', (e) => { - // Forward to all clients (they decrypt what's theirs) - this.clients.forEach(c => c.send(e.data)) - }) - } else { - this.clients.set(clientId, server) - server.addEventListener('message', (e) => { - this.daemon?.send(e.data) - }) - server.addEventListener('close', () => { - this.clients.delete(clientId) - }) - } - - return new Response(null, { status: 101, webSocket: client }) - } -} -``` - -### Worker Entry Point - -```typescript -export default { - async fetch(req: Request, env: Env) { - const url = new URL(req.url) - const daemonId = url.pathname.split('/')[2] // /link/ - - const id = env.LINK_ROOMS.idFromName(daemonId) - const room = env.LINK_ROOMS.get(id) - - return room.fetch(req) - } -} -``` - -### Pricing Estimate - -- Durable Objects: $0.15/million requests -- WebSocket messages count as requests -- Storage: $0.15/GB-month (minimal for Link) -- Expected cost for personal use: < $1/month - -## Future Enhancements - -- **Homebrew formula** for easier Mac installation -- **Docker image** for server deployments -- **Bundled Node binary** (pkg/nexe) for non-Node users -- **Auto-update mechanism** via npm or custom updater diff --git a/logo.png b/logo.png deleted file mode 100644 index 58274a4f1..000000000 Binary files a/logo.png and /dev/null differ diff --git a/packages/app/01-initial-state.png b/packages/app/01-initial-state.png deleted file mode 100644 index a948d8116..000000000 Binary files a/packages/app/01-initial-state.png and /dev/null differ diff --git a/packages/app/assets/images/favicon-dark-attention.png b/packages/app/assets/images/favicon-dark-attention.png index f6abc7d4f..fe821b1c5 100644 Binary files a/packages/app/assets/images/favicon-dark-attention.png and b/packages/app/assets/images/favicon-dark-attention.png differ diff --git a/packages/app/assets/images/favicon-dark-attention.svg b/packages/app/assets/images/favicon-dark-attention.svg index 04da62667..1fdc70a29 100644 --- a/packages/app/assets/images/favicon-dark-attention.svg +++ b/packages/app/assets/images/favicon-dark-attention.svg @@ -1,4 +1,5 @@ + diff --git a/packages/app/assets/images/favicon-dark-running.png b/packages/app/assets/images/favicon-dark-running.png index ac0ec5f41..ff5cfc4c5 100644 Binary files a/packages/app/assets/images/favicon-dark-running.png and b/packages/app/assets/images/favicon-dark-running.png differ diff --git a/packages/app/assets/images/favicon-dark-running.svg b/packages/app/assets/images/favicon-dark-running.svg index 3257fb17f..f5a9a9cca 100644 --- a/packages/app/assets/images/favicon-dark-running.svg +++ b/packages/app/assets/images/favicon-dark-running.svg @@ -1,4 +1,5 @@ + diff --git a/packages/app/assets/images/favicon-dark.png b/packages/app/assets/images/favicon-dark.png index 7dd4f4c7c..9b31651ba 100644 Binary files a/packages/app/assets/images/favicon-dark.png and b/packages/app/assets/images/favicon-dark.png differ diff --git a/packages/app/assets/images/favicon-dark.svg b/packages/app/assets/images/favicon-dark.svg index d1b4b5785..cc4ac3ecc 100644 --- a/packages/app/assets/images/favicon-dark.svg +++ b/packages/app/assets/images/favicon-dark.svg @@ -1,3 +1,4 @@ + diff --git a/packages/app/assets/images/favicon-light-attention.png b/packages/app/assets/images/favicon-light-attention.png index 508decaa2..fe821b1c5 100644 Binary files a/packages/app/assets/images/favicon-light-attention.png and b/packages/app/assets/images/favicon-light-attention.png differ diff --git a/packages/app/assets/images/favicon-light-attention.svg b/packages/app/assets/images/favicon-light-attention.svg index 548b02252..1fdc70a29 100644 --- a/packages/app/assets/images/favicon-light-attention.svg +++ b/packages/app/assets/images/favicon-light-attention.svg @@ -1,4 +1,5 @@ - + + diff --git a/packages/app/assets/images/favicon-light-running.png b/packages/app/assets/images/favicon-light-running.png index 78ad3c984..ff5cfc4c5 100644 Binary files a/packages/app/assets/images/favicon-light-running.png and b/packages/app/assets/images/favicon-light-running.png differ diff --git a/packages/app/assets/images/favicon-light-running.svg b/packages/app/assets/images/favicon-light-running.svg index 934e79274..f5a9a9cca 100644 --- a/packages/app/assets/images/favicon-light-running.svg +++ b/packages/app/assets/images/favicon-light-running.svg @@ -1,4 +1,5 @@ - + + diff --git a/packages/app/assets/images/favicon-light.png b/packages/app/assets/images/favicon-light.png index f658ab664..9b31651ba 100644 Binary files a/packages/app/assets/images/favicon-light.png and b/packages/app/assets/images/favicon-light.png differ diff --git a/packages/app/assets/images/favicon-light.svg b/packages/app/assets/images/favicon-light.svg index c722dabc6..cc4ac3ecc 100644 --- a/packages/app/assets/images/favicon-light.svg +++ b/packages/app/assets/images/favicon-light.svg @@ -1,3 +1,4 @@ - + + diff --git a/packages/app/assets/images/favicon.png b/packages/app/assets/images/favicon.png index 7dd4f4c7c..9b31651ba 100644 Binary files a/packages/app/assets/images/favicon.png and b/packages/app/assets/images/favicon.png differ diff --git a/packages/app/assets/images/icon.png b/packages/app/assets/images/icon.png index 9b877f4ef..d0e4e3fe0 100644 Binary files a/packages/app/assets/images/icon.png and b/packages/app/assets/images/icon.png differ diff --git a/packages/app/assets/images/splash-icon.png b/packages/app/assets/images/splash-icon.png index 21f7da928..720f883ed 100644 Binary files a/packages/app/assets/images/splash-icon.png and b/packages/app/assets/images/splash-icon.png differ diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index b8c0546ad..db869b881 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -2,7 +2,7 @@ import { test, expect, type Page } from '@playwright/test'; const consoleEntries = new WeakMap(); -test.beforeEach(({ page }) => { +test.beforeEach(async ({ page }) => { const entries: string[] = []; consoleEntries.set(page, entries); @@ -13,6 +13,21 @@ test.beforeEach(({ page }) => { page.on('pageerror', (error) => { entries.push(`[pageerror] ${error.message}`); }); + + // Set up test daemon connection if available + const daemonPort = process.env.E2E_DAEMON_PORT; + if (daemonPort) { + const testDaemon = { + id: 'e2e-test-daemon', + label: 'localhost', + wsUrl: `ws://localhost:${daemonPort}/ws`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await page.addInitScript((daemon) => { + localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); + }, testDaemon); + } }); test.afterEach(async ({ page }, testInfo) => { diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts new file mode 100644 index 000000000..d2c37ea3a --- /dev/null +++ b/packages/app/e2e/global-setup.ts @@ -0,0 +1,88 @@ +import { spawn, type ChildProcess, execSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to acquire port'))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForServer(port: number, timeout = 15000): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + await new Promise((resolve, reject) => { + const socket = net.connect(port, 'localhost', () => { + socket.end(); + resolve(); + }); + socket.on('error', reject); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error(`Server did not start on port ${port} within ${timeout}ms`); +} + +let daemonProcess: ChildProcess | null = null; +let paseoHome: string | null = null; + +export default async function globalSetup() { + const port = await getAvailablePort(); + paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-')); + + const serverDir = path.resolve(__dirname, '../../..', 'packages/server'); + const tsxBin = execSync('which tsx').toString().trim(); + + daemonProcess = spawn(tsxBin, ['src/server/index.ts'], { + cwd: serverDir, + env: { + ...process.env, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `0.0.0.0:${port}`, + PASEO_CORS_ORIGINS: 'http://localhost:8081', + NODE_ENV: 'development', + }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }); + + daemonProcess.stdout?.on('data', (data: Buffer) => { + console.log(`[daemon] ${data.toString().trim()}`); + }); + + daemonProcess.stderr?.on('data', (data: Buffer) => { + console.error(`[daemon] ${data.toString().trim()}`); + }); + + await waitForServer(port); + + process.env.E2E_DAEMON_PORT = String(port); + console.log(`[e2e] Test daemon started on port ${port}, home: ${paseoHome}`); + + return async () => { + if (daemonProcess) { + daemonProcess.kill('SIGTERM'); + daemonProcess = null; + } + if (paseoHome) { + await rm(paseoHome, { recursive: true, force: true }); + paseoHome = null; + } + console.log('[e2e] Test daemon stopped'); + }; +} diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 89288ffec..375eeb2ae 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -59,3 +59,105 @@ export const createAgent = async (page: Page, message: string) => { await page.waitForURL(/\/agent\//); await expect(page.getByText(message, { exact: true })).toBeVisible(); }; + +export interface AgentConfig { + directory: string; + provider?: string; + model?: string; + mode?: string; + prompt: string; +} + +export const selectProvider = async (page: Page, provider: string) => { + const providerLabel = page.getByText('PROVIDER', { exact: true }).first(); + await expect(providerLabel).toBeVisible(); + await providerLabel.click(); + + const option = page.getByText(provider, { exact: true }).first(); + await expect(option).toBeVisible(); + await option.click(); +}; + +export const selectModel = async (page: Page, model: string) => { + const modelLabel = page.getByText('MODEL', { exact: true }).first(); + await expect(modelLabel).toBeVisible(); + await modelLabel.click(); + + // Wait for the model dropdown to open + const searchInput = page.getByRole('textbox', { name: /search model/i }); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + + // Type to search/filter models + await searchInput.fill(model); + + // Wait for a matching option to appear (partial match via regex) + const option = page.getByText(new RegExp(model, 'i')).first(); + await expect(option).toBeVisible({ timeout: 30000 }); + await option.click(); + + // Wait for dropdown to close + await expect(searchInput).not.toBeVisible({ timeout: 5000 }); +}; + +export const selectMode = async (page: Page, mode: string) => { + const modeLabel = page.getByText('MODE', { exact: true }).first(); + await expect(modeLabel).toBeVisible(); + await modeLabel.click(); + + // Wait for the mode dropdown to open + const searchInput = page.getByRole('textbox', { name: /search mode/i }); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + + // Type to filter modes + await searchInput.fill(mode); + + // Click the matching option (use last() since the field label also contains the mode text) + const option = page.getByText(mode, { exact: true }).last(); + await expect(option).toBeVisible(); + await option.click(); + + // Wait for dropdown to close + await expect(searchInput).not.toBeVisible({ timeout: 5000 }); +}; + +export const createAgentWithConfig = async (page: Page, config: AgentConfig) => { + await gotoHome(page); + await ensureHostSelected(page); + await setWorkingDirectory(page, config.directory); + + if (config.provider) { + await selectProvider(page, config.provider); + } + + if (config.model) { + await selectModel(page, config.model); + } + + if (config.mode) { + await selectMode(page, config.mode); + } + + await createAgent(page, config.prompt); +}; + +export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => { + const promptText = page.getByText('How would you like to proceed?').first(); + await expect(promptText).toBeVisible({ timeout }); +}; + +export const allowPermission = async (page: Page) => { + const allowButton = page.getByText('Allow', { exact: true }).first(); + await expect(allowButton).toBeVisible({ timeout: 5000 }); + await allowButton.click(); +}; + +export const denyPermission = async (page: Page) => { + const denyButton = page.getByText('Deny', { exact: true }).first(); + await expect(denyButton).toBeVisible({ timeout: 5000 }); + await denyButton.click(); +}; + +export async function waitForAgentIdle(page: Page, timeout = 30000) { + const stopButton = page.getByRole('button', { name: /stop|cancel/i }); + await expect(stopButton).not.toBeVisible({ timeout }); +} diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index 1fb1a2487..7d974f806 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -13,6 +13,8 @@ export const createTempGitRepo = async (prefix = 'paseo-e2e-'): Promise { + test('allow permission creates the file', async ({ page }) => { + const repo = await createTempGitRepo(); + const uniqueFilename = `test-allow-${Date.now()}.txt`; + const filePath = path.join(repo.path, uniqueFilename); + const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`; + + try { + await createAgentWithConfig(page, { + directory: repo.path, + model: 'haiku', + mode: 'Always Ask', + prompt, + }); + + await waitForPermissionPrompt(page, 30000); + await allowPermission(page); + + // Wait for file to be created + await expect + .poll(() => existsSync(filePath), { + message: `File ${filePath} should exist after allowing permission`, + timeout: 10000, + }) + .toBe(true); + } finally { + await repo.cleanup(); + } + }); + + test('deny permission does not create the file', async ({ page }) => { + const repo = await createTempGitRepo(); + const uniqueFilename = `test-deny-${Date.now()}.txt`; + const filePath = path.join(repo.path, uniqueFilename); + const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`; + + try { + await createAgentWithConfig(page, { + directory: repo.path, + model: 'haiku', + mode: 'Always Ask', + prompt, + }); + + await waitForPermissionPrompt(page, 30000); + await denyPermission(page); + await waitForAgentIdle(page); + + expect(existsSync(filePath)).toBe(false); + } finally { + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index d6ca80907..b2bf47c85 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -4,6 +4,7 @@ const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:8081'; export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', timeout: 60_000, expect: { timeout: 10_000, diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 094efe08b..6d2e3428e 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -17,6 +17,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState, useEffect, type ReactNode, useMemo } from "react"; import { Platform } from "react-native"; import { SlidingSidebar } from "@/components/sliding-sidebar"; +import { DownloadToast } from "@/components/download-toast"; import { usePanelStore } from "@/stores/panel-store"; import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated"; import { @@ -158,6 +159,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { {children} {isMobile && } + ); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index bcf1b6fee..cf21c52ba 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -16,7 +16,6 @@ import Markdown from "react-native-markdown-display"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useMutation } from "@tanstack/react-query"; -import { Fonts } from "@/constants/theme"; import Animated, { FadeIn, FadeOut, @@ -41,18 +40,14 @@ import { MessageOuterSpacingProvider, type InlinePathTarget, } from "./message"; -import { DiffViewer } from "./diff-viewer"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; import { useSessionStore } from "@/stores/session-store"; import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; -import { - extractCommandDetails, - extractEditEntries, - extractReadEntries, -} from "@/utils/tool-call-parsers"; +import { parseToolCallDisplay } from "@/utils/tool-call-parsers"; +import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallSheetProvider } from "./tool-call-sheet"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; @@ -762,9 +757,6 @@ function PermissionRequestCard({ const { request } = permission; const title = request.title ?? request.name ?? "Permission Required"; const description = request.description ?? ""; - const inputPreview = request.input - ? JSON.stringify(request.input, null, 2) - : null; const planMarkdown = useMemo(() => { if (!request) { @@ -784,19 +776,9 @@ function PermissionRequestCard({ return undefined; }, [request]); - const editEntries = useMemo( - () => extractEditEntries(request.input, request.metadata), - [request] - ); - - const readEntries = useMemo( - () => extractReadEntries(request.input, request.metadata), - [request] - ); - - const commandDetails = useMemo( - () => extractCommandDetails(request.input, request.metadata), - [request] + const toolCallDisplay = useMemo( + () => parseToolCallDisplay(request.input, null), + [request.input] ); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); @@ -1016,187 +998,7 @@ function PermissionRequestCard({ ) : null} - {commandDetails ? ( - - - Command - - {commandDetails.command ? ( - - - Command - - - {commandDetails.command} - - - ) : null} - {commandDetails.cwd ? ( - - - Directory - - - {commandDetails.cwd} - - - ) : null} - - ) : null} - - {editEntries.length > 0 ? ( - - - Proposed Changes - - {editEntries.map((entry, index) => ( - - {entry.filePath ? ( - - - {entry.filePath} - - - ) : null} - - - - - ))} - - ) : null} - - {readEntries.length > 0 ? ( - - - File Content - - {readEntries.map((entry, index) => ( - - {entry.filePath ? ( - - {entry.filePath} - - ) : null} - - {entry.content} - - - ))} - - ) : null} - - {inputPreview ? ( - - - Raw Request - - - - {inputPreview} - - - - ) : null} + ({ flexShrink: 1, minWidth: 0, }, - metadataRow: { - marginBottom: theme.spacing[2], - }, - metadataLabel: { - fontSize: theme.fontSize.xs, - textTransform: "uppercase" as const, - letterSpacing: 0.5, - }, - metadataValue: { - fontFamily: Fonts.mono, - fontSize: theme.fontSize.sm, - }, - diffSection: { - gap: theme.spacing[2], - }, - fileBadge: { - alignSelf: "flex-start", - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - }, - fileBadgeText: { - fontFamily: Fonts.mono, - fontSize: theme.fontSize.xs, - }, - diffWrapper: { - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - overflow: "hidden", - }, - rawContentText: { - fontFamily: Fonts.mono, - fontSize: theme.fontSize.sm, - lineHeight: 20, - }, question: { fontSize: theme.fontSize.sm, marginTop: theme.spacing[2], diff --git a/packages/app/src/components/download-toast.tsx b/packages/app/src/components/download-toast.tsx new file mode 100644 index 000000000..41ecb2492 --- /dev/null +++ b/packages/app/src/components/download-toast.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Check, X, XCircle } from "lucide-react-native"; +import { useDownloadStore, formatSpeed, formatEta } from "@/stores/download-store"; + +const AUTO_DISMISS_DELAY = 3000; + +export function DownloadToast() { + const { theme } = useUnistyles(); + const downloads = useDownloadStore((state) => state.downloads); + const activeDownloadId = useDownloadStore((state) => state.activeDownloadId); + const dismissDownload = useDownloadStore((state) => state.dismissDownload); + const dismissTimeoutRef = useRef | null>(null); + + const activeDownload = activeDownloadId ? downloads.get(activeDownloadId) : null; + + useEffect(() => { + if (dismissTimeoutRef.current) { + clearTimeout(dismissTimeoutRef.current); + dismissTimeoutRef.current = null; + } + + if (activeDownload && activeDownload.status !== "downloading") { + dismissTimeoutRef.current = setTimeout(() => { + dismissDownload(activeDownload.id); + }, AUTO_DISMISS_DELAY); + } + + return () => { + if (dismissTimeoutRef.current) { + clearTimeout(dismissTimeoutRef.current); + } + }; + }, [activeDownload, dismissDownload]); + + if (!activeDownload) { + return null; + } + + return ( + + + {activeDownload.status === "downloading" ? ( + + ) : activeDownload.status === "complete" ? ( + + ) : ( + + )} + + + {activeDownload.fileName} + + + {activeDownload.status === "downloading" + ? activeDownload.progress + ? `${Math.round(activeDownload.progress.percent * 100)}% · ${formatSpeed(activeDownload.progress.speed)} · ${formatEta(activeDownload.progress.eta)}` + : "Starting..." + : activeDownload.status === "complete" + ? "Download complete" + : activeDownload.message ?? "Download failed"} + + {activeDownload.status === "downloading" && activeDownload.progress && ( + + + + )} + + {activeDownload.status !== "downloading" && ( + dismissDownload(activeDownload.id)} + hitSlop={8} + style={styles.dismiss} + > + + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + position: "absolute", + bottom: theme.spacing[4], + left: theme.spacing[4], + right: theme.spacing[4], + zIndex: 1000, + }, + toast: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 8, + }, + textContainer: { + flex: 1, + gap: theme.spacing[1], + }, + fileName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + status: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + progressBar: { + height: 3, + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.full, + marginTop: theme.spacing[1], + overflow: "hidden", + }, + progressFill: { + height: "100%", + backgroundColor: theme.colors.primary, + borderRadius: theme.borderRadius.full, + }, + dismiss: { + padding: theme.spacing[1], + }, +})); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 95a1406ca..4617b1c66 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -10,7 +10,6 @@ import { NativeScrollEvent, NativeSyntheticEvent, Modal, - Platform, Pressable, ScrollView, Text, @@ -20,9 +19,6 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; -import { File as FSFile, Paths } from "expo-file-system"; -import * as LegacyFileSystem from "expo-file-system/legacy"; -import * as Sharing from "expo-sharing"; import { BottomSheetModal, BottomSheetScrollView, @@ -30,7 +26,6 @@ import { BottomSheetView, } from "@gorhom/bottom-sheet"; import { - Check, ChevronDown, Download, File, @@ -39,12 +34,11 @@ import { Image as ImageIcon, MoreVertical, X, - XCircle, } from "lucide-react-native"; import type { ExplorerEntry } from "@/stores/session-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import type { DaemonProfile } from "@/contexts/daemon-registry-context"; import { useSessionStore } from "@/stores/session-store"; +import { useDownloadStore } from "@/stores/download-store"; import { usePanelStore, type SortOption, @@ -170,21 +164,7 @@ export function FileExplorerPane({ const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 }); const [menuHeight, setMenuHeight] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); - const [downloadToast, setDownloadToast] = useState<{ - status: "downloading" | "complete" | "error"; - fileName: string; - message?: string; - progress?: { - percent: number; - bytesWritten: number; - totalBytes: number; - speed: number; - eta: number; - }; - } | null>(null); - const downloadStartTimeRef = useRef(0); - const lastProgressRef = useRef<{ bytes: number; time: number } | null>(null); - const downloadToastTimeoutRef = useRef | null>(null); + const startDownload = useDownloadStore((state) => state.startDownload); const agentIdRef = useRef(agentId); const viewModeRef = useRef(viewMode); const requestFilePreviewRef = useRef(requestFilePreview); @@ -434,121 +414,22 @@ export function FileExplorerPane({ setMenuHeight((current) => (current === height ? current : height)); }, []); - const showDownloadToast = useCallback( - (toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => { - if (downloadToastTimeoutRef.current) { - clearTimeout(downloadToastTimeoutRef.current); - downloadToastTimeoutRef.current = null; - } - setDownloadToast(toast); - if (toast.status !== "downloading") { - downloadToastTimeoutRef.current = setTimeout(() => { - setDownloadToast(null); - }, 3000); - } - }, - [] - ); - const handleDownloadEntry = useCallback( - async (entry: ExplorerEntry) => { + (entry: ExplorerEntry) => { if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { return; } - const displayName = entry.name; - - try { - const tokenResponse = await requestFileDownloadToken(agentId, entry.path); - if (tokenResponse.error || !tokenResponse.token) { - throw new Error(tokenResponse.error ?? "Failed to request download token."); - } - - const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); - if (!downloadTarget.baseUrl) { - throw new Error("Download host is unavailable."); - } - - const fileName = tokenResponse.fileName ?? entry.name; - const downloadUrl = buildDownloadUrl( - downloadTarget.baseUrl, - tokenResponse.token, - Platform.OS === "web" ? downloadTarget.authCredentials : null - ); - - if (Platform.OS === "web") { - triggerBrowserDownload(downloadUrl, fileName); - return; - } - - downloadStartTimeRef.current = Date.now(); - lastProgressRef.current = null; - showDownloadToast({ status: "downloading", fileName: displayName }); - - const targetFile = resolveDownloadTargetFile(fileName); - const downloadResumable = LegacyFileSystem.createDownloadResumable( - downloadUrl, - targetFile.uri, - downloadTarget.authHeader - ? { headers: { Authorization: downloadTarget.authHeader } } - : undefined, - (data) => { - const now = Date.now(); - const { totalBytesWritten, totalBytesExpectedToWrite } = data; - - if (totalBytesExpectedToWrite <= 0) { - return; - } - - const percent = totalBytesWritten / totalBytesExpectedToWrite; - const elapsed = (now - downloadStartTimeRef.current) / 1000; - const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0; - const remaining = totalBytesExpectedToWrite - totalBytesWritten; - const eta = speed > 0 ? remaining / speed : 0; - - lastProgressRef.current = { bytes: totalBytesWritten, time: now }; - - setDownloadToast((prev) => - prev?.status === "downloading" - ? { - ...prev, - progress: { - percent, - bytesWritten: totalBytesWritten, - totalBytes: totalBytesExpectedToWrite, - speed, - eta, - }, - } - : prev - ); - } - ); - - const result = await downloadResumable.downloadAsync(); - if (!result) { - throw new Error("Download was cancelled."); - } - - showDownloadToast({ status: "complete", fileName: displayName }); - - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(result.uri, { - mimeType: tokenResponse.mimeType ?? undefined, - dialogTitle: fileName ? `Share ${fileName}` : "Share file", - }); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to download file."; - if (Platform.OS === "web") { - console.warn("[FileExplorer] Download failed:", message); - return; - } - showDownloadToast({ status: "error", fileName: displayName, message }); - } + startDownload({ + serverId, + agentId, + fileName: entry.name, + path: entry.path, + daemonProfile, + requestFileDownloadToken, + }); }, - [agentId, daemonProfile, requestFileDownloadToken, showDownloadToast] + [agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload] ); const menuPosition = useMemo(() => { @@ -991,53 +872,6 @@ export function FileExplorerPane({ )} - - {downloadToast && ( - - - {downloadToast.status === "downloading" ? ( - - ) : downloadToast.status === "complete" ? ( - - ) : ( - - )} - - - {downloadToast.fileName} - - - {downloadToast.status === "downloading" - ? downloadToast.progress - ? `${Math.round(downloadToast.progress.percent * 100)}% · ${formatSpeed(downloadToast.progress.speed)} · ${formatEta(downloadToast.progress.eta)}` - : "Starting..." - : downloadToast.status === "complete" - ? "Download complete" - : downloadToast.message ?? "Download failed"} - - {downloadToast.status === "downloading" && downloadToast.progress && ( - - - - )} - - {downloadToast.status !== "downloading" && ( - setDownloadToast(null)} - hitSlop={8} - style={styles.downloadToastDismiss} - > - - - )} - - - )} ); } @@ -1056,28 +890,6 @@ function formatFileSize({ size }: { size: number }): string { return `${(size / (1024 * 1024)).toFixed(1)} MB`; } -function formatSpeed(bytesPerSecond: number): string { - if (bytesPerSecond < 1024) { - return `${Math.round(bytesPerSecond)} B/s`; - } - if (bytesPerSecond < 1024 * 1024) { - return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`; - } - return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; -} - -function formatEta(seconds: number): string { - if (seconds < 1) { - return "< 1s"; - } - if (seconds < 60) { - return `${Math.round(seconds)}s`; - } - const mins = Math.floor(seconds / 60); - const secs = Math.round(seconds % 60); - return `${mins}m ${secs}s`; -} - type EntryDisplayKind = "directory" | "image" | "text" | "other"; const IMAGE_EXTENSIONS = new Set([ @@ -1176,120 +988,6 @@ function getExtension(name: string): string | null { return name.slice(index + 1).toLowerCase(); } -type DownloadTarget = { - baseUrl: string | null; - authHeader: string | null; - authCredentials: { username: string; password: string } | null; -}; - -function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget { - const rawUrl = daemon?.restUrl ?? daemon?.wsUrl; - if (!rawUrl) { - return { baseUrl: null, authHeader: null, authCredentials: null }; - } - - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - return { baseUrl: null, authHeader: null, authCredentials: null }; - } - - if (parsed.protocol === "ws:") { - parsed.protocol = "http:"; - } else if (parsed.protocol === "wss:") { - parsed.protocol = "https:"; - } - - let authCredentials: { username: string; password: string } | null = null; - if (parsed.username || parsed.password) { - authCredentials = { - username: decodeURIComponent(parsed.username), - password: decodeURIComponent(parsed.password), - }; - parsed.username = ""; - parsed.password = ""; - } - - parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/"); - - const baseUrl = parsed.origin; - const authHeader = authCredentials - ? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}` - : null; - - return { baseUrl, authHeader, authCredentials }; -} - -function buildDownloadUrl( - baseUrl: string, - token: string, - authCredentials: { username: string; password: string } | null -): string { - const url = new URL("/api/files/download", baseUrl); - url.searchParams.set("token", token); - if (authCredentials) { - url.username = authCredentials.username; - url.password = authCredentials.password; - } - return url.toString(); -} - -function triggerBrowserDownload(url: string, fileName: string) { - if (typeof document === "undefined") { - if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener"); - } - return; - } - - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - link.rel = "noopener"; - document.body.appendChild(link); - link.click(); - link.remove(); -} - -function resolveDownloadTargetFile(fileName: string): FSFile { - const directory = Paths.cache ?? Paths.document; - if (!directory) { - throw new Error("No download directory available."); - } - - const safeName = sanitizeDownloadFileName(fileName); - const split = splitFileName(safeName); - let targetFile = new FSFile(directory, safeName); - let suffix = 1; - - while (targetFile.exists) { - targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`); - suffix += 1; - } - - return targetFile; -} - -function sanitizeDownloadFileName(fileName: string): string { - const trimmed = fileName.trim(); - if (!trimmed) { - return "download"; - } - return trimmed.replace(/[\\/:*?"<>|]+/g, "_"); -} - -function splitFileName(fileName: string): { base: string; ext: string } { - const lastDot = fileName.lastIndexOf("."); - if (lastDot <= 0) { - return { base: fileName, ext: "" }; - } - return { - base: fileName.slice(0, lastDot), - ext: fileName.slice(lastDot), - }; -} - const styles = StyleSheet.create((theme) => ({ container: { flex: 1, @@ -1577,55 +1275,4 @@ const styles = StyleSheet.create((theme) => ({ width: "100%", aspectRatio: 1, }, - downloadToast: { - position: "absolute", - bottom: theme.spacing[4], - left: theme.spacing[4], - right: theme.spacing[4], - zIndex: 1000, - }, - downloadToastContent: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 8, - }, - downloadToastTextContainer: { - flex: 1, - gap: theme.spacing[1], - }, - downloadToastFileName: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - downloadToastStatus: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - downloadProgressBar: { - height: 3, - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.full, - marginTop: theme.spacing[1], - overflow: "hidden", - }, - downloadProgressFill: { - height: "100%", - backgroundColor: theme.colors.primary, - borderRadius: theme.borderRadius.full, - }, - downloadToastDismiss: { - padding: theme.spacing[1], - }, })); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index b33fd984f..7f0c47cc2 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -286,6 +286,11 @@ const DiffFileSection = memo(function DiffFileSection({ New )} + {file.isDeleted && ( + + Deleted + + )} +{file.additions} @@ -579,6 +584,18 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, color: theme.colors.palette.green[400], }, + deletedBadge: { + backgroundColor: "rgba(248, 81, 73, 0.2)", + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + flexShrink: 0, + }, + deletedBadgeText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.red[500], + }, additions: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts new file mode 100644 index 000000000..c125465e6 --- /dev/null +++ b/packages/app/src/stores/download-store.ts @@ -0,0 +1,376 @@ +import { create } from "zustand"; +import { Platform } from "react-native"; +import { File as FSFile, Paths } from "expo-file-system"; +import * as LegacyFileSystem from "expo-file-system/legacy"; +import * as Sharing from "expo-sharing"; +import type { DaemonProfile } from "@/contexts/daemon-registry-context"; + +interface DownloadProgress { + percent: number; + bytesWritten: number; + totalBytes: number; + speed: number; + eta: number; +} + +export interface Download { + id: string; + serverId: string; + agentId: string; + fileName: string; + status: "downloading" | "complete" | "error"; + message?: string; + progress?: DownloadProgress; + startedAt: number; +} + +interface DownloadState { + downloads: Map; + activeDownloadId: string | null; + + startDownload: (params: { + serverId: string; + agentId: string; + fileName: string; + path: string; + daemonProfile: DaemonProfile | undefined; + requestFileDownloadToken: ( + agentId: string, + path: string + ) => Promise<{ + token: string | null; + fileName: string | null; + mimeType: string | null; + error: string | null; + }>; + }) => Promise; + + updateProgress: (id: string, progress: DownloadProgress) => void; + completeDownload: (id: string) => void; + failDownload: (id: string, message: string) => void; + dismissDownload: (id: string) => void; + dismissAllCompleted: () => void; +} + +function generateDownloadId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export const useDownloadStore = create()((set, get) => ({ + downloads: new Map(), + activeDownloadId: null, + + startDownload: async ({ + serverId, + agentId, + fileName, + path, + daemonProfile, + requestFileDownloadToken, + }) => { + const id = generateDownloadId(); + const download: Download = { + id, + serverId, + agentId, + fileName, + status: "downloading", + startedAt: Date.now(), + }; + + set((state) => ({ + downloads: new Map(state.downloads).set(id, download), + activeDownloadId: id, + })); + + try { + const tokenResponse = await requestFileDownloadToken(agentId, path); + if (tokenResponse.error || !tokenResponse.token) { + throw new Error(tokenResponse.error ?? "Failed to request download token."); + } + + const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); + if (!downloadTarget.baseUrl) { + throw new Error("Download host is unavailable."); + } + + const resolvedFileName = tokenResponse.fileName ?? fileName; + const downloadUrl = buildDownloadUrl( + downloadTarget.baseUrl, + tokenResponse.token, + Platform.OS === "web" ? downloadTarget.authCredentials : null + ); + + if (Platform.OS === "web") { + triggerBrowserDownload(downloadUrl, resolvedFileName); + get().completeDownload(id); + return; + } + + const downloadStartTime = Date.now(); + const targetFile = resolveDownloadTargetFile(resolvedFileName); + const downloadResumable = LegacyFileSystem.createDownloadResumable( + downloadUrl, + targetFile.uri, + downloadTarget.authHeader + ? { headers: { Authorization: downloadTarget.authHeader } } + : undefined, + (data) => { + const now = Date.now(); + const { totalBytesWritten, totalBytesExpectedToWrite } = data; + + if (totalBytesExpectedToWrite <= 0) { + return; + } + + const percent = totalBytesWritten / totalBytesExpectedToWrite; + const elapsed = (now - downloadStartTime) / 1000; + const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0; + const remaining = totalBytesExpectedToWrite - totalBytesWritten; + const eta = speed > 0 ? remaining / speed : 0; + + get().updateProgress(id, { + percent, + bytesWritten: totalBytesWritten, + totalBytes: totalBytesExpectedToWrite, + speed, + eta, + }); + } + ); + + const result = await downloadResumable.downloadAsync(); + if (!result) { + throw new Error("Download was cancelled."); + } + + get().completeDownload(id); + + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(result.uri, { + mimeType: tokenResponse.mimeType ?? undefined, + dialogTitle: resolvedFileName ? `Share ${resolvedFileName}` : "Share file", + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to download file."; + if (Platform.OS === "web") { + console.warn("[DownloadStore] Download failed:", message); + get().failDownload(id, message); + return; + } + get().failDownload(id, message); + } + }, + + updateProgress: (id, progress) => { + set((state) => { + const download = state.downloads.get(id); + if (!download || download.status !== "downloading") { + return state; + } + const updated = new Map(state.downloads); + updated.set(id, { ...download, progress }); + return { downloads: updated }; + }); + }, + + completeDownload: (id) => { + set((state) => { + const download = state.downloads.get(id); + if (!download) { + return state; + } + const updated = new Map(state.downloads); + updated.set(id, { ...download, status: "complete" }); + return { downloads: updated }; + }); + }, + + failDownload: (id, message) => { + set((state) => { + const download = state.downloads.get(id); + if (!download) { + return state; + } + const updated = new Map(state.downloads); + updated.set(id, { ...download, status: "error", message }); + return { downloads: updated }; + }); + }, + + dismissDownload: (id) => { + set((state) => { + const updated = new Map(state.downloads); + updated.delete(id); + const newActiveId = + state.activeDownloadId === id + ? findMostRecentDownloadId(updated) + : state.activeDownloadId; + return { downloads: updated, activeDownloadId: newActiveId }; + }); + }, + + dismissAllCompleted: () => { + set((state) => { + const updated = new Map(state.downloads); + for (const [id, download] of updated) { + if (download.status !== "downloading") { + updated.delete(id); + } + } + const newActiveId = state.activeDownloadId + ? updated.has(state.activeDownloadId) + ? state.activeDownloadId + : findMostRecentDownloadId(updated) + : null; + return { downloads: updated, activeDownloadId: newActiveId }; + }); + }, +})); + +function findMostRecentDownloadId(downloads: Map): string | null { + let mostRecent: Download | null = null; + for (const download of downloads.values()) { + if (!mostRecent || download.startedAt > mostRecent.startedAt) { + mostRecent = download; + } + } + return mostRecent?.id ?? null; +} + +type DownloadTarget = { + baseUrl: string | null; + authHeader: string | null; + authCredentials: { username: string; password: string } | null; +}; + +function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget { + const rawUrl = daemon?.restUrl ?? daemon?.wsUrl; + if (!rawUrl) { + return { baseUrl: null, authHeader: null, authCredentials: null }; + } + + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return { baseUrl: null, authHeader: null, authCredentials: null }; + } + + if (parsed.protocol === "ws:") { + parsed.protocol = "http:"; + } else if (parsed.protocol === "wss:") { + parsed.protocol = "https:"; + } + + let authCredentials: { username: string; password: string } | null = null; + if (parsed.username || parsed.password) { + authCredentials = { + username: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + }; + parsed.username = ""; + parsed.password = ""; + } + + parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/"); + + const baseUrl = parsed.origin; + const authHeader = authCredentials + ? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}` + : null; + + return { baseUrl, authHeader, authCredentials }; +} + +function buildDownloadUrl( + baseUrl: string, + token: string, + authCredentials: { username: string; password: string } | null +): string { + const url = new URL("/api/files/download", baseUrl); + url.searchParams.set("token", token); + if (authCredentials) { + url.username = authCredentials.username; + url.password = authCredentials.password; + } + return url.toString(); +} + +function triggerBrowserDownload(url: string, fileName: string) { + if (typeof document === "undefined") { + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener"); + } + return; + } + + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function resolveDownloadTargetFile(fileName: string): FSFile { + const directory = Paths.cache ?? Paths.document; + if (!directory) { + throw new Error("No download directory available."); + } + + const safeName = sanitizeDownloadFileName(fileName); + const split = splitFileName(safeName); + let targetFile = new FSFile(directory, safeName); + let suffix = 1; + + while (targetFile.exists) { + targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`); + suffix += 1; + } + + return targetFile; +} + +function sanitizeDownloadFileName(fileName: string): string { + const trimmed = fileName.trim(); + if (!trimmed) { + return "download"; + } + return trimmed.replace(/[\\/:*?"<>|]+/g, "_"); +} + +function splitFileName(fileName: string): { base: string; ext: string } { + const lastDot = fileName.lastIndexOf("."); + if (lastDot <= 0) { + return { base: fileName, ext: "" }; + } + return { + base: fileName.slice(0, lastDot), + ext: fileName.slice(lastDot), + }; +} + +export function formatSpeed(bytesPerSecond: number): string { + if (bytesPerSecond < 1024) { + return `${Math.round(bytesPerSecond)} B/s`; + } + if (bytesPerSecond < 1024 * 1024) { + return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`; + } + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; +} + +export function formatEta(seconds: number): string { + if (seconds < 1) { + return "< 1s"; + } + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${mins}m ${secs}s`; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f6c7d55e8..0a558e8e8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2174,6 +2174,23 @@ export class Session { { cwd: agent.cwd } ); + // Get file statuses (A=added, D=deleted, M=modified) to detect deleted files + const { stdout: nameStatusOutput } = await execAsync( + "git diff --name-status HEAD", + { cwd: agent.cwd } + ); + const deletedFiles = new Set(); + const addedFiles = new Set(); + for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) { + const [status, ...pathParts] = line.split("\t"); + const path = pathParts.join("\t"); + if (status === "D") { + deletedFiles.add(path); + } else if (status === "A") { + addedFiles.add(path); + } + } + // Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary interface FileStats { path: string; @@ -2181,6 +2198,8 @@ export class Session { deletions: number; isBinary: boolean; isTracked: boolean; + isDeleted: boolean; + isNew: boolean; } const fileStats: FileStats[] = []; @@ -2196,6 +2215,8 @@ export class Session { deletions: isBinary ? 0 : parseInt(delStr, 10), isBinary, isTracked: true, + isDeleted: deletedFiles.has(path), + isNew: addedFiles.has(path), }); } } @@ -2224,6 +2245,8 @@ export class Session { deletions: 0, isBinary, isTracked: false, + isDeleted: false, + isNew: true, }); } catch { // If we can't determine, assume text and try to get it @@ -2233,6 +2256,8 @@ export class Session { deletions: 0, isBinary: false, isTracked: false, + isDeleted: false, + isNew: true, }); } } @@ -2250,8 +2275,8 @@ export class Session { if (stats.isBinary) { allFiles.push({ path: stats.path, - isNew: !stats.isTracked, - isDeleted: false, + isNew: stats.isNew, + isDeleted: stats.isDeleted, additions: 0, deletions: 0, hunks: [], @@ -2264,8 +2289,8 @@ export class Session { if (totalLines > MAX_DIFF_LINES) { allFiles.push({ path: stats.path, - isNew: !stats.isTracked, - isDeleted: false, + isNew: stats.isNew, + isDeleted: stats.isDeleted, additions: stats.additions, deletions: stats.deletions, hunks: [], @@ -2301,8 +2326,8 @@ export class Session { // If diff fails for this file, add it with empty hunks allFiles.push({ path: stats.path, - isNew: !stats.isTracked, - isDeleted: false, + isNew: stats.isNew, + isDeleted: stats.isDeleted, additions: stats.additions, deletions: stats.deletions, hunks: [], diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 8f5249f3d..d5d865c3e 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -9,6 +9,8 @@ import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; type TestPaseoDaemonOptions = { basicUsers?: Record; downloadTokenTtlMs?: number; + corsAllowedOrigins?: string[]; + listen?: string; }; export type TestPaseoDaemon = { @@ -59,12 +61,13 @@ export async function createTestPaseoDaemon( const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const port = await getAvailablePort(); + const listenHost = options.listen ?? '127.0.0.1'; const config: PaseoDaemonConfig = { - listen: `127.0.0.1:${port}`, + listen: `${listenHost}:${port}`, paseoHome, - corsAllowedOrigins: [], + corsAllowedOrigins: options.corsAllowedOrigins ?? [], agentMcpRoute: "/mcp/agents", - agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], + agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`], auth: { basicUsers, agentMcpAuthHeader,