diff --git a/package-lock.json b/package-lock.json index 758152811..bbbd4552d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16710,6 +16710,33 @@ "node": ">= 0.6" } }, + "node_modules/framer-motion": { + "version": "12.35.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.2.tgz", + "integrity": "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.35.2", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/freeport-async": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", @@ -20790,6 +20817,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.35.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.2.tgz", + "integrity": "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -28097,6 +28139,7 @@ "@cloudflare/workers-types": "^4.20260114.0", "@tanstack/react-router": "^1.120.3", "@tanstack/react-start": "^1.120.3", + "framer-motion": "^12.35.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 30f3c1c35..bd1f64c47 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -597,7 +597,7 @@ export function AgentInputArea({ const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false const handleToggleRealtimeVoice = useCallback(() => { - if (!voice || !isConnected) { + if (!voice || !isConnected || !agent) { return } if (voice.isVoiceSwitching) { @@ -614,7 +614,7 @@ export function AgentInputArea({ toast.error(message) } }) - }, [agentId, isConnected, serverId, toast, voice]) + }, [agent, agentId, isConnected, serverId, toast, voice]) function handleEditQueuedMessage(id: string) { const item = queuedMessages.find((q) => q.id === id) @@ -704,7 +704,7 @@ export function AgentInputArea({ const rightContent = ( - {!isVoiceModeForAgent ? ( + {!isVoiceModeForAgent && agent ? ( void listFooterComponent?: ReactElement | null + showAttentionIndicator?: boolean } interface AgentListSection { @@ -112,12 +113,14 @@ function SessionRow({ agent, isMobile, selectedAgentId, + showAttentionIndicator, onPress, onLongPress, }: { agent: AggregatedAgent isMobile: boolean selectedAgentId?: string + showAttentionIndicator: boolean onPress: (agent: AggregatedAgent) => void onLongPress: (agent: AggregatedAgent) => void }) { @@ -154,7 +157,7 @@ function SessionRow({ {(agent.pendingPermissionCount ?? 0) > 0 ? ( ) : null} - {!isMobile && agent.requiresAttention ? ( + {!isMobile && showAttentionIndicator && agent.requiresAttention ? ( ) : null} @@ -187,7 +190,7 @@ function SessionRow({ {timeAgo} )} - {isMobile && agent.requiresAttention ? ( + {isMobile && showAttentionIndicator && agent.requiresAttention ? ( @@ -200,12 +203,14 @@ function SessionTableSection({ section, isMobile, selectedAgentId, + showAttentionIndicator, onAgentPress, onAgentLongPress, }: { section: AgentListSection isMobile: boolean selectedAgentId?: string + showAttentionIndicator: boolean onAgentPress: (agent: AggregatedAgent) => void onAgentLongPress: (agent: AggregatedAgent) => void }) { @@ -225,6 +230,7 @@ function SessionTableSection({ agent={agent} isMobile={isMobile} selectedAgentId={selectedAgentId} + showAttentionIndicator={showAttentionIndicator} onPress={onAgentPress} onLongPress={onAgentLongPress} /> @@ -242,6 +248,7 @@ export function AgentList({ selectedAgentId, onAgentSelect, listFooterComponent, + showAttentionIndicator = true, }: AgentListProps) { const { theme } = useUnistyles() const pathname = usePathname() @@ -325,11 +332,12 @@ export function AgentList({ section={section} isMobile={isMobile} selectedAgentId={selectedAgentId} + showAttentionIndicator={showAttentionIndicator} onAgentPress={handleAgentPress} onAgentLongPress={handleAgentLongPress} /> ), - [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId] + [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator] ) const keyExtractor = useCallback((section: AgentListSection) => section.key, []) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 7f27146c4..2ae51e0b7 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -24,7 +24,7 @@ import { router, usePathname } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' import * as Clipboard from 'expo-clipboard' -import { ChevronDown, ChevronRight, MoreVertical, Plus } from 'lucide-react-native' +import { Archive, ChevronDown, ChevronRight, Copy, 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' @@ -43,7 +43,6 @@ import { } from '@/hooks/use-sidebar-workspaces-list' import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' -import { formatTimeAgo } from '@/utils/time' import { ContextMenu, ContextMenuContent, @@ -126,12 +125,6 @@ interface WorkspaceRowInnerProps { onCopyPath?: () => void } -function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null { - if (!workspace.activityAt) { - return null - } - return formatTimeAgo(workspace.activityAt) -} function resolveStatusDotColor(input: { theme: ReturnType['theme'] @@ -620,7 +613,8 @@ function WorkspaceRowInner({ onCopyPath, }: WorkspaceRowInnerProps) { const { theme } = useUnistyles() - const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) + const [isHovered, setIsHovered] = useState(false) + const isMobile = Platform.OS !== 'web' const interaction = useLongPressDragInteraction({ drag, menuController, @@ -636,14 +630,18 @@ function WorkspaceRowInner({ }, [interaction.didLongPressRef, onPress]) return ( - + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + > [ + style={({ pressed }) => [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, selected && styles.sidebarRowSelected, - hovered && styles.workspaceRowHovered, + isHovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} onPressIn={interaction.handlePressIn} @@ -659,41 +657,43 @@ function WorkspaceRowInner({ style={styles.workspaceRowLeft} > - + {workspace.name} - {workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : createdAtLabel ? ( - - {createdAtLabel} - - ) : null} {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} ) : null} - {onArchive ? ( + {onArchive && (isHovered || isMobile) ? ( [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} accessibilityRole="button" accessibilityLabel="Workspace actions" testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} > - + {({ hovered }) => ( + + )} {onCopyPath ? ( } onSelect={onCopyPath} > Copy path @@ -701,15 +701,20 @@ function WorkspaceRowInner({ ) : null} } status={archiveStatus} pendingLabel={archivePendingLabel} - destructive onSelect={onArchive} > {archiveLabel ?? 'Archive'} + ) : workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + ) : null} @@ -1726,10 +1731,8 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, }, - workspaceCreatedAtText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - flexShrink: 0, + workspaceBranchTextHovered: { + opacity: 1, }, diffStatRow: { flexDirection: 'row', @@ -1752,6 +1755,9 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: 4, marginLeft: 2, }, + kebabButtonHovered: { + backgroundColor: theme.colors.surface2, + }, shortcutBadge: { minWidth: 18, height: 18, diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 2c1da28fd..4d219ab55 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -8,6 +8,7 @@ import { useState, type PropsWithChildren, type ReactElement, + type ReactNode, } from "react"; import { ActivityIndicator, @@ -184,8 +185,9 @@ type TriggerStyleProp = | StyleProp | ((state: TriggerState) => StyleProp); -interface DropdownMenuTriggerProps extends Omit { +interface DropdownMenuTriggerProps extends Omit { style?: TriggerStyleProp; + children: ReactNode | ((state: TriggerState) => ReactNode); } export function DropdownMenuTrigger({ @@ -193,7 +195,7 @@ export function DropdownMenuTrigger({ disabled, style, ...props -}: PropsWithChildren): ReactElement { +}: DropdownMenuTriggerProps): ReactElement { const ctx = useDropdownMenuContext("DropdownMenuTrigger"); const handlePress = useCallback(() => { @@ -215,7 +217,10 @@ export function DropdownMenuTrigger({ return style; }} > - {children} + {({ pressed, hovered = false }) => { + const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open }; + return typeof children === "function" ? children(state) : children; + }} ); } diff --git a/packages/app/src/screens/agents-screen.tsx b/packages/app/src/screens/agents-screen.tsx index e816eeeb3..689428bff 100644 --- a/packages/app/src/screens/agents-screen.tsx +++ b/packages/app/src/screens/agents-screen.tsx @@ -52,6 +52,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) { showCheckoutInfo={false} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} + showAttentionIndicator={false} /> ); diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index bf56c5cde..4bb8baf8b 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -291,7 +291,12 @@ describe("ClaudeAgentSession interrupt restart regression", () => { await firstTurn.next(); const secondTurnPromise = collectUntilTerminal(session.stream("second prompt")); - await Promise.resolve(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (sdkMocks.secondQuery) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } sdkMocks.releaseOldAssistant?.(); const secondTurnEvents = await secondTurnPromise; @@ -309,6 +314,93 @@ describe("ClaudeAgentSession interrupt restart regression", () => { await session.close(); }); + test("ignores stale interrupted query completion after the replacement run starts", async () => { + const logger = createTestLogger(); + const releaseOldDone = deferred(); + let queryCreateCount = 0; + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryCreateCount += 1; + if (queryCreateCount === 1) { + let step = 0; + const mock = { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "interrupt-stale-done-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + await releaseOldDone.promise; + step += 1; + return { done: true, value: undefined }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + } satisfies QueryMock; + sdkMocks.firstQuery = mock; + return mock; + } + + const mock = buildSecondQueryMock(prompt); + if (queryCreateCount === 2) { + sdkMocks.secondQuery = mock; + } + return mock; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const firstTurn = session.stream("first prompt"); + await firstTurn.next(); + + const secondTurnPromise = collectUntilTerminal(session.stream("second prompt")); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (sdkMocks.secondQuery) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + releaseOldDone.resolve(undefined); + + const secondTurnEvents = await secondTurnPromise; + const secondAssistantText = collectAssistantText(secondTurnEvents); + + expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(1); + expect(sdkMocks.secondQuery?.next).toHaveBeenCalled(); + expect(secondAssistantText).toContain("NEW_TURN_RESPONSE"); + expect( + secondTurnEvents.some( + (event) => + event.type === "turn_failed" && + event.error.includes("Claude stream ended before terminal result") + ) + ).toBe(false); + expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true); + + await firstTurn.return?.(); + await session.close(); + }); + test("ignores stale task-notification assistant/result events queued before the current prompt", async () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 91fb51308..933206fad 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1038,7 +1038,7 @@ async function startAgentMcpServer(): Promise { let capturedQuestion: AgentPermissionRequest | null = null; let sawResolvedAllow = false; - let sawDone = false; + let assistantText = ""; for await (const event of session.stream(prompt)) { if ( @@ -1071,10 +1071,9 @@ async function startAgentMcpServer(): Promise { if ( event.type === "timeline" && - event.item.type === "assistant_message" && - event.item.text.includes("QUESTION_FLOW_DONE") + event.item.type === "assistant_message" ) { - sawDone = true; + assistantText += event.item.text; } if (event.type === "turn_completed" || event.type === "turn_failed") { @@ -1085,7 +1084,7 @@ async function startAgentMcpServer(): Promise { expect(capturedQuestion).not.toBeNull(); expect(sawResolvedAllow).toBe(true); expect(session.getPendingPermissions()).toHaveLength(0); - expect(sawDone).toBe(true); + expect(assistantText).toContain("QUESTION_FLOW_DONE"); } finally { await closeSessionAndCleanup(session, cwd); } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index abfb21fdb..9a226813b 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1418,12 +1418,12 @@ export class ClaudeAgentClient implements AgentClient { if (this.claudePath) { try { const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim(); - this.logger.info({ claudePath: this.claudePath, version }, "Resolved Claude binary"); + this.logger.trace({ claudePath: this.claudePath, version }, "Resolved Claude binary"); } catch { - this.logger.info({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)"); + this.logger.trace({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)"); } } else { - this.logger.warn("Claude binary not found in PATH; SDK will use bundled binary"); + this.logger.trace("Claude binary not found in PATH; SDK will use bundled binary"); } } @@ -2864,7 +2864,7 @@ class ClaudeAgentSession implements AgentSession { } const pump = this.runQueryPump().catch((error) => { - this.logger.warn({ err: error }, "Claude query pump exited unexpectedly"); + this.logger.trace({ err: error }, "Claude query pump exited unexpectedly"); }); this.queryPumpPromise = pump; @@ -2886,7 +2886,7 @@ class ClaudeAgentSession implements AgentSession { try { q = await this.ensureQuery(); } catch (error) { - this.logger.warn({ err: error }, "Failed to initialize Claude query pump"); + this.logger.trace({ err: error }, "Failed to initialize Claude query pump"); await this.waitForLiveHistoryPoll(); continue; } @@ -2894,12 +2894,23 @@ class ClaudeAgentSession implements AgentSession { let next: IteratorResult; try { next = await q.next(); - this.logger.info( + this.logger.trace( { claudeSessionId: this.claudeSessionId, next }, "Claude query pump raw next()" ); } catch (error) { - this.logger.warn({ err: error }, "Claude query pump next() failed"); + if (this.query !== q) { + this.logger.trace( + { err: error, staleQuery: true }, + "Ignoring Claude query pump next() failure from replaced query" + ); + await this.awaitWithTimeout( + q.return?.(), + "query pump return after stale failure" + ); + continue; + } + this.logger.trace({ err: error }, "Claude query pump next() failed"); for (const run of this.runTracker.listActiveRuns()) { this.failRun( run, @@ -2917,6 +2928,21 @@ class ClaudeAgentSession implements AgentSession { } if (next.done) { + if (this.query !== q) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + activeRunCount: this.runTracker.listActiveRuns().length, + staleQuery: true, + }, + "Ignoring replaced Claude query pump completion" + ); + await this.awaitWithTimeout( + q.return?.(), + "query pump return on stale done" + ); + continue; + } this.logger.trace( { claudeSessionId: this.claudeSessionId, @@ -2945,6 +2971,22 @@ class ClaudeAgentSession implements AgentSession { continue; } + if (this.query !== q) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + messageType: sdkMessage.type, + staleQuery: true, + }, + "Ignoring Claude SDK message from replaced query" + ); + await this.awaitWithTimeout( + q.return?.(), + "query pump return on stale message" + ); + continue; + } + if (await this.handleMissingResumedConversation(sdkMessage, q)) { continue; } @@ -2952,7 +2994,7 @@ class ClaudeAgentSession implements AgentSession { try { this.routeSdkMessageFromPump(sdkMessage); } catch (error) { - this.logger.warn({ err: error }, "Failed to route Claude SDK message from query pump"); + this.logger.trace({ err: error }, "Failed to route Claude SDK message from query pump"); } } } @@ -3229,22 +3271,22 @@ class ClaudeAgentSession implements AgentSession { private async interruptActiveTurn(): Promise { const queryToInterrupt = this.query; if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") { - this.logger.debug("interruptActiveTurn: no query to interrupt"); + this.logger.trace("interruptActiveTurn: no query to interrupt"); return; } try { - this.logger.debug("interruptActiveTurn: calling query.interrupt()..."); + this.logger.trace("interruptActiveTurn: calling query.interrupt()..."); const t0 = Date.now(); await queryToInterrupt.interrupt(); - this.logger.debug({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned"); + this.logger.trace({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned"); // After interrupt(), the query iterator is done (returns done: true). // Clear it so ensureQuery() creates a fresh query for the next turn. // Also end the input stream and call return() to clean up the SDK process. this.input?.end(); - this.logger.debug("interruptActiveTurn: calling query.return()..."); + this.logger.trace("interruptActiveTurn: calling query.return()..."); const t1 = Date.now(); await queryToInterrupt.return?.(); - this.logger.debug({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned"); + this.logger.trace({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned"); this.query = null; this.input = null; this.queryRestartNeeded = false; diff --git a/packages/website/package.json b/packages/website/package.json index 041e03f76..1cb692fc9 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -15,6 +15,7 @@ "@cloudflare/workers-types": "^4.20260114.0", "@tanstack/react-router": "^1.120.3", "@tanstack/react-start": "^1.120.3", + "framer-motion": "^12.35.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 56beb73a2..e7a43f027 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -1,11 +1,65 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' +import { motion, AnimatePresence } from 'framer-motion' import { CursorFieldProvider } from '~/components/butterfly' import websitePackage from '../../package.json' import '~/styles.css' const desktopVersion = websitePackage.version -const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_aarch64.dmg` +const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}` + +type Platform = 'mac-silicon' | 'mac-intel' | 'windows' | 'linux' + +interface DownloadOption { + platform: Platform + label: string + href: string + icon: (props: React.SVGProps) => React.ReactElement +} + +const downloadOptions: DownloadOption[] = [ + { + platform: 'mac-silicon', + label: 'Mac', + href: `${releaseBase}/Paseo_${desktopVersion}_aarch64.dmg`, + icon: AppleIcon, + }, + { + platform: 'mac-intel', + label: 'Mac Intel', + href: `${releaseBase}/Paseo_${desktopVersion}_x86_64.dmg`, + icon: AppleIcon, + }, + { + platform: 'windows', + label: 'Windows', + href: `${releaseBase}/Paseo_${desktopVersion}_x64-setup.exe`, + icon: WindowsIcon, + }, + { + platform: 'linux', + label: 'Linux', + href: `${releaseBase}/Paseo_${desktopVersion}_amd64.AppImage`, + icon: LinuxIcon, + }, +] + +function useDetectedPlatform(): Platform { + const [platform, setPlatform] = React.useState('mac-silicon') + + React.useEffect(() => { + const ua = navigator.userAgent.toLowerCase() + if (ua.includes('win')) { + setPlatform('windows') + } else if (ua.includes('linux') && !ua.includes('android')) { + setPlatform('linux') + } else if (ua.includes('mac')) { + setPlatform('mac-silicon') + } + }, []) + + return platform +} export const Route = createFileRoute('/')({ head: () => ({ @@ -36,7 +90,12 @@ function Home() { {/* Mockup - inside hero so it's above the gradient, positioned to overflow into black section */} -
+
-
+ {/* Content section */} @@ -114,16 +173,53 @@ function Nav() { function Hero() { return (
-

+ One interface for all your coding agents -

-

- Run Claude Code, Codex, and OpenCode on your machine. Connect from your phone or desktop. -

+ + + Run agents in parallel on your own machines. Ship from your phone or your desk. +
) } +function AgentBadge({ name, icon }: { name: string; icon: React.ReactNode }) { + const [hovered, setHovered] = React.useState(false) + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {icon} + + {hovered && ( + + {name} + + )} + + + ) +} + function Differentiator({ title, description }: { title: string; description: string }) { return (
@@ -137,108 +233,246 @@ function Features() { return (
- - - - - - - - + {[ + { title: 'Self-hosted', description: 'The daemon runs on your laptop, home server, or VPS. Allowing you to take full advantage of your dev environment.' }, + { title: 'Multi-provider', description: 'Works with existing agent harnesses like Claude Code, Codex, and OpenCode from one interface.' }, + { title: 'Multi-host', description: 'Connect to multiple daemons and see all your agents in one place.' }, + { title: 'First-class voice', description: 'Real-time voice conversations and dictation. Talk to your agent, hear responses, and orchestrate work hands-free.' }, + { title: 'Optional relay', description: 'Use the hosted end-to-end encrypted relay for remote access, or connect directly over your network.' }, + { title: 'Cross-device', description: 'Jump seamlessly between iOS, Android, desktop, web, and CLI.' }, + { title: 'Git integration', description: 'Manage agents in isolated worktrees. Review diffs and ship directly from the app.' }, + { title: 'Open source', description: 'Free and open source. Run it yourself, fork it, contribute.' }, + ].map((feature, i) => ( + +

{feature.title}

+

{feature.description}

+
+ ))}
) } -function Feature({ title, description }: { title: string; description: string }) { - return ( -
-

{title}

-

{description}

-
- ) -} - function GetStarted() { return ( -
- -

Install the daemon

- npm install -g @getpaseo/cli && paseo -
- -

Download any app

-
- - - Download for Mac - - - - Launch Web App - - - - - Coming soon - - - - - - Coming soon - + +
+ + + + Launch Web App + + + + + + + + Coming soon + + +
+
+ Supports +
+ } /> + } /> + } />
- +
+
+ ) +} + +function DownloadButton() { + const detectedPlatform = useDetectedPlatform() + const [open, setOpen] = React.useState(false) + const ref = React.useRef(null) + + React.useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const primary = downloadOptions.find((o) => o.platform === detectedPlatform)! + const secondaryOptions = downloadOptions.filter((o) => o.platform !== detectedPlatform) + const PrimaryIcon = primary.icon + + return ( +
+
+ + + Download for {primary.label} + + +
+ + {open && ( + + {secondaryOptions.map((option) => { + const Icon = option.icon + return ( + setOpen(false)} + className="flex items-center gap-2 px-4 py-2 text-sm text-white/80 hover:bg-white/10 transition-colors" + > + + {option.label} + + ) + })} + + )} +
) } +function ServerInstallButton() { + const [open, setOpen] = React.useState(false) + const ref = React.useRef(null) + + React.useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + return ( +
+ + + {open && ( + <> + setOpen(false)} + /> + +
+

Run agents on a remote machine

+

+ For headless machines you want to connect to from the Paseo apps. The desktop app already includes a built-in daemon. +

+
+ npm install -g @getpaseo/cli && paseo +

+ Requires Node.js 18+. Run paseo to start the daemon. +

+
+ + )} +
+
+ ) +} + +function ClaudeCodeIcon(props: React.SVGProps) { + return ( + + ) +} + +function CodexIcon(props: React.SVGProps) { + return ( + + ) +} + +function OpenCodeIcon(props: React.SVGProps) { + return ( + + ) +} + function AppleIcon(props: React.SVGProps) { return ( ) } @@ -303,6 +537,72 @@ function GooglePlayIcon(props: React.SVGProps) { ) } +function WindowsIcon(props: React.SVGProps) { + return ( + + ) +} + +function LinuxIcon(props: React.SVGProps) { + return ( + + ) +} + +function TerminalIcon(props: React.SVGProps) { + return ( + + ) +} + +function ChevronDownIcon(props: React.SVGProps) { + return ( + + ) +} + function GlobeIcon(props: React.SVGProps) { return ( +

Background

@@ -403,13 +709,19 @@ function Story() { testing different agents, so locking myself to a single harness or model wasn't an option.

-
+ ) } function FAQ() { return ( -
+

FAQ

@@ -433,7 +745,7 @@ function FAQ() { Stroll, in Spanish. πŸšΆβ€β™‚οΈ
-
+ ) }