feat: add multi-platform downloads and improve homepage animations

This commit is contained in:
Mohamed Boudra
2026-03-10 11:20:17 +07:00
parent a3e271a1e7
commit cc45c3772f
11 changed files with 661 additions and 152 deletions

43
package-lock.json generated
View File

@@ -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",

View File

@@ -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 = (
<View style={styles.rightControls}>
{!isVoiceModeForAgent ? (
{!isVoiceModeForAgent && agent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}

View File

@@ -27,6 +27,7 @@ interface AgentListProps {
selectedAgentId?: string
onAgentSelect?: () => 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 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
) : null}
{!isMobile && agent.requiresAttention ? (
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" />
) : null}
</View>
@@ -187,7 +190,7 @@ function SessionRow({
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
</>
)}
{isMobile && agent.requiresAttention ? (
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" />
</View>
@@ -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, [])

View File

@@ -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<typeof useUnistyles>['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 (
<View style={styles.workspaceRowContainer}>
<View
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<Pressable
disabled={isArchiving}
style={({ pressed, hovered = 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}
>
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<Text style={styles.workspaceBranchText} numberOfLines={1}>
<Text
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]}
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 ? (
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={styles.kebabButton}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
<MoreVertical size={14} color={theme.colors.foregroundMuted} />
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
@@ -701,15 +701,20 @@ function WorkspaceRowInner({
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
status={archiveStatus}
pendingLabel={archivePendingLabel}
destructive
onSelect={onArchive}
>
{archiveLabel ?? 'Archive'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
</View>
</Pressable>
@@ -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,

View File

@@ -8,6 +8,7 @@ import {
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from "react";
import {
ActivityIndicator,
@@ -184,8 +185,9 @@ type TriggerStyleProp =
| StyleProp<ViewStyle>
| ((state: TriggerState) => StyleProp<ViewStyle>);
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style"> {
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
style?: TriggerStyleProp;
children: ReactNode | ((state: TriggerState) => ReactNode);
}
export function DropdownMenuTrigger({
@@ -193,7 +195,7 @@ export function DropdownMenuTrigger({
disabled,
style,
...props
}: PropsWithChildren<DropdownMenuTriggerProps>): 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;
}}
</Pressable>
);
}

View File

@@ -52,6 +52,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
</View>
);

View File

@@ -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<void>();
let queryCreateCount = 0;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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();

View File

@@ -1038,7 +1038,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
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<AgentMcpServerHandle> {
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<AgentMcpServerHandle> {
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);
}

View File

@@ -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<SDKMessage, void>;
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<void> {
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;

View File

@@ -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",

View File

@@ -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<SVGSVGElement>) => 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<Platform>('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() {
</div>
{/* Mockup - inside hero so it's above the gradient, positioned to overflow into black section */}
<div className="relative px-6 md:px-8 pb-8 md:pb-16">
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5, ease: 'easeOut' }}
className="relative px-6 md:px-8 pb-8 md:pb-16"
>
<div className="max-w-6xl mx-auto">
<img
src="/paseo-mockup.png"
@@ -44,7 +103,7 @@ function Home() {
className="w-full rounded-lg shadow-2xl"
/>
</div>
</div>
</motion.div>
</div>
{/* Content section */}
@@ -114,16 +173,53 @@ function Nav() {
function Hero() {
return (
<div className="space-y-6">
<h1 className="text-3xl md:text-5xl font-bold tracking-tight">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="text-3xl md:text-5xl font-semibold 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 on your machine. Connect from your phone or desktop.
</p>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.15, ease: 'easeOut' }}
className="text-white/70 text-lg leading-relaxed max-w-lg"
>
Run agents in parallel on your own machines. Ship from your phone or your desk.
</motion.p>
</div>
)
}
function AgentBadge({ name, icon }: { name: string; icon: React.ReactNode }) {
const [hovered, setHovered] = React.useState(false)
return (
<span
className="relative inline-flex items-center justify-center rounded-full p-1.5 text-white/60"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{icon}
<AnimatePresence>
{hovered && (
<motion.span
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 rounded bg-white text-black text-xs whitespace-nowrap pointer-events-none"
>
{name}
</motion.span>
)}
</AnimatePresence>
</span>
)
}
function Differentiator({ title, description }: { title: string; description: string }) {
return (
<div>
@@ -137,108 +233,246 @@ function Features() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<Feature
title="Self-hosted"
description="The daemon runs on your laptop, home server, or VPS. Allowing you to take full advantage of your dev environment."
/>
<Feature
title="Multi-provider"
description="Works with existing agent harnesses like Claude Code, Codex, and OpenCode from one interface."
/>
<Feature
title="Multi-host"
description="Connect to multiple daemons and see all your agents in one place."
/>
<Feature
title="First-class voice"
description="Real-time voice conversations and dictation. Talk to your agent, hear responses, and orchestrate work hands-free."
/>
<Feature
title="Optional relay"
description="Use the hosted end-to-end encrypted relay for remote access, or connect directly over your network."
/>
<Feature
title="Cross-device"
description="Jump seamlessly between iOS, Android, desktop, web, and CLI."
/>
<Feature
title="Git integration"
description="Manage agents in isolated worktrees. Review diffs and ship directly from the app."
/>
<Feature
title="Open source"
description="Free and open source. Run it yourself, fork it, contribute."
/>
{[
{ 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) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-40px' }}
transition={{ duration: 0.4, delay: i * 0.06, ease: 'easeOut' }}
className="space-y-1"
>
<p className="font-medium text-base">{feature.title}</p>
<p className="text-sm text-white/60">{feature.description}</p>
</motion.div>
))}
</div>
</div>
)
}
function Feature({ title, description }: { title: string; description: string }) {
return (
<div className="space-y-1">
<p className="font-medium text-base">{title}</p>
<p className="text-sm text-white/60">{description}</p>
</div>
)
}
function GetStarted() {
return (
<div className="pt-10 space-y-6">
<Step number={1}>
<p className="text-sm text-white/70">Install the daemon</p>
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
</Step>
<Step number={2}>
<p className="text-sm text-white/70">Download any app</p>
<div className="flex flex-row gap-3">
<a
href={macDownloadHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
<AppleIcon className="h-4 w-4" />
Download for Mac
</a>
<a
href="https://app.paseo.sh"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
<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">
<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">
<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
</span>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4, ease: 'easeOut' }}
className="pt-10"
>
<div className="flex flex-row flex-wrap gap-3">
<DownloadButton />
<a
href="https://app.paseo.sh"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
<GlobeIcon className="h-4 w-4" />
Launch Web App
</a>
<a
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
aria-label="App Store"
>
<AppleIcon className="h-5 w-5" />
</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">
<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
</span>
</span>
<ServerInstallButton />
</div>
<div className="flex items-center gap-2 pt-8">
<span className="text-xs text-white/40">Supports</span>
<div className="flex items-center gap-1">
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon className="h-6 w-6" />} />
<AgentBadge name="Codex" icon={<CodexIcon className="h-6 w-6" />} />
<AgentBadge name="OpenCode" icon={<OpenCodeIcon className="h-6 w-6" />} />
</div>
</Step>
</div>
</motion.div>
)
}
function DownloadButton() {
const detectedPlatform = useDetectedPlatform()
const [open, setOpen] = React.useState(false)
const ref = React.useRef<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<div className="flex items-stretch">
<a
href={primary.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-l-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
>
<PrimaryIcon className="h-4 w-4" />
Download for {primary.label}
</a>
<button
onClick={() => setOpen(!open)}
className="inline-flex items-center justify-center rounded-r-lg border-l border-background/20 bg-foreground px-2 py-2 text-background hover:bg-foreground/90 transition-colors"
aria-label="More download options"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] rounded-lg border border-white/20 bg-black/90 backdrop-blur-sm py-1"
>
{secondaryOptions.map((option) => {
const Icon = option.icon
return (
<a
key={option.platform}
href={option.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-white/80 hover:bg-white/10 transition-colors"
>
<Icon className="h-4 w-4" />
{option.label}
</a>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function ServerInstallButton() {
const [open, setOpen] = React.useState(false)
const ref = React.useRef<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
aria-label="Install on a server"
>
<TerminalIcon className="h-5 w-5" />
</button>
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-xl border border-white/20 bg-background p-6 space-y-4"
>
<div className="space-y-2">
<p className="text-base font-medium text-white">Run agents on a remote machine</p>
<p className="text-sm text-white/60">
For headless machines you want to connect to from the Paseo apps. The desktop app already includes a built-in daemon.
</p>
</div>
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
<p className="text-xs text-white/30">
Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to start the daemon.
</p>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
function ClaudeCodeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" aria-hidden="true" {...props}>
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</svg>
)
}
function CodexIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" aria-hidden="true" {...props}>
<path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" />
</svg>
)
}
function OpenCodeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="96 64 288 384" fill="currentColor" aria-hidden="true" {...props}>
<path d="M320 224V352H192V224H320Z" opacity="0.4" />
<path fillRule="evenodd" clipRule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" />
</svg>
)
}
function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1408 1664"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M1393 1215q-39 125-123 250q-129 196-257 196q-49 0-140-32q-86-32-151-32q-61 0-142 33q-81 34-132 34q-152 0-301-259Q0 1144 0 902q0-228 113-374q113-144 284-144q72 0 177 30q104 30 138 30q45 0 143-34q102-34 173-34q119 0 213 65q52 36 104 100q-79 67-114 118q-65 94-65 207q0 124 69 223t158 126M1017 42q0 61-29 136q-30 75-93 138q-54 54-108 72q-37 11-104 17q3-149 78-257Q835 41 1011 0q1 3 2.5 11t2.5 11q0 4 .5 10t.5 10" />
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
)
}
@@ -303,6 +537,72 @@ function GooglePlayIcon(props: React.SVGProps<SVGSVGElement>) {
)
}
function WindowsIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623" />
</svg>
)
}
function LinuxIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M8.996 4.497c.104-.076.1-.168.186-.158s.022.102-.098.207c-.12.104-.308.243-.46.323-.291.152-.631.336-.993.336s-.647-.167-.853-.33c-.102-.082-.186-.162-.248-.221-.11-.086-.096-.207-.052-.204.075.01.087.109.134.153.064.06.144.137.241.214.195.154.454.304.778.304s.702-.19.932-.32c.13-.073.297-.204.433-.304M7.34 3.781c.055-.02.123-.031.174-.003.011.006.024.021.02.034-.012.038-.074.032-.11.05-.032.017-.057.052-.093.054-.034 0-.086-.012-.09-.046-.007-.044.058-.072.1-.089m.581-.003c.05-.028.119-.018.173.003.041.017.106.045.1.09-.004.033-.057.046-.09.045-.036-.002-.062-.037-.093-.053-.036-.019-.098-.013-.11-.051-.004-.013.008-.028.02-.034" />
<path fillRule="evenodd" d="M8.446.019c2.521.003 2.38 2.66 2.364 4.093-.01.939.509 1.574 1.04 2.244.474.56 1.095 1.38 1.45 2.32.29.765.402 1.613.115 2.465a.8.8 0 0 1 .254.152l.001.002c.207.175.271.447.329.698.058.252.112.488.224.615.344.382.494.667.48.922-.015.254-.203.43-.435.57-.465.28-1.164.491-1.586 1.002-.443.527-.99.83-1.505.871a1.25 1.25 0 0 1-1.256-.716v-.001a1 1 0 0 1-.078-.21c-.67.038-1.252-.165-1.718-.128-.687.038-1.116.204-1.506.206-.151.331-.445.547-.808.63-.5.114-1.126 0-1.743-.324-.577-.306-1.31-.278-1.85-.39-.27-.057-.51-.157-.626-.384-.116-.226-.095-.538.07-.988.051-.16.012-.398-.026-.648a2.5 2.5 0 0 1-.037-.369c0-.133.022-.265.087-.386v-.002c.14-.266.368-.377.577-.451s.397-.125.53-.258c.143-.15.27-.374.443-.56q.036-.037.073-.07c-.081-.538.007-1.105.192-1.662.393-1.18 1.223-2.314 1.811-3.014.502-.713.65-1.287.701-2.016.042-.997-.705-3.974 2.112-4.2q.168-.015.321-.013m2.596 10.866-.03.016c-.223.121-.348.337-.427.656-.08.32-.107.733-.13 1.206v.001c-.023.37-.192.824-.31 1.267s-.176.862-.036 1.128v.002c.226.452.608.636 1.051.601s.947-.304 1.36-.795c.474-.576 1.218-.796 1.638-1.05.21-.126.324-.242.333-.4.009-.157-.097-.403-.425-.767-.17-.192-.217-.462-.274-.71-.056-.247-.122-.468-.26-.585l-.001-.001c-.18-.157-.356-.17-.565-.164q-.069.001-.14.005c-.239.275-.805.612-1.197.508-.359-.09-.562-.508-.587-.918m-7.204.03H3.83c-.189.002-.314.09-.44.225-.149.158-.276.382-.445.56v.002h-.002c-.183.184-.414.239-.61.31-.195.069-.353.143-.46.35v.002c-.085.155-.066.378-.029.624.038.245.096.507.018.746v.002l-.001.002c-.157.427-.155.678-.082.822.074.143.235.22.48.272.493.103 1.26.069 1.906.41.583.305 1.168.404 1.598.305.431-.098.712-.369.75-.867v-.002c.029-.292-.195-.673-.485-1.052-.29-.38-.633-.752-.795-1.09v-.002l-.61-1.11c-.21-.286-.43-.462-.68-.5a1 1 0 0 0-.106-.008M9.584 4.85c-.14.2-.386.37-.695.467-.147.048-.302.17-.495.28a1.3 1.3 0 0 1-.74.19.97.97 0 0 1-.582-.227c-.14-.113-.25-.237-.394-.322a3 3 0 0 1-.192-.126c-.063 1.179-.85 2.658-1.226 3.511a5.4 5.4 0 0 0-.43 1.917c-.68-.906-.184-2.066.081-2.568.297-.55.343-.701.27-.649-.266.436-.685 1.13-.848 1.844-.085.372-.1.749.01 1.097.11.349.345.67.766.931.573.351.963.703 1.193 1.015s.302.584.23.777a.4.4 0 0 1-.212.22.7.7 0 0 1-.307.056l.184.235c.094.124.186.249.266.375 1.179.805 2.567.496 3.568-.218.1-.342.197-.664.212-.903.024-.474.05-.896.136-1.245s.244-.634.53-.791a1 1 0 0 1 .138-.061q.005-.045.013-.087c.082-.546.569-.572 1.18-.303.588.266.81.499.71.814h.13c.122-.398-.133-.69-.822-1.025l-.137-.06a2.35 2.35 0 0 0-.012-1.113c-.188-.79-.704-1.49-1.098-1.838-.072-.003-.065.06.081.203.363.333 1.156 1.532.727 2.644a1.2 1.2 0 0 0-.342-.043c-.164-.907-.543-1.66-.735-2.014-.359-.668-.918-2.036-1.158-2.983M7.72 3.503a1 1 0 0 0-.312.053c-.268.093-.447.286-.559.391-.022.021-.05.04-.119.091s-.172.126-.321.238q-.198.151-.13.38c.046.15.192.325.459.476.166.098.28.23.41.334a1 1 0 0 0 .215.133.9.9 0 0 0 .298.066c.282.017.49-.068.673-.173s.34-.233.518-.29c.365-.115.627-.345.709-.564a.37.37 0 0 0-.01-.309c-.048-.096-.148-.187-.318-.257h-.001c-.354-.151-.507-.162-.705-.29-.321-.207-.587-.28-.807-.279m-.89-1.122h-.025a.4.4 0 0 0-.278.135.76.76 0 0 0-.191.334 1.2 1.2 0 0 0-.051.445v.001c.01.162.041.299.102.436.05.116.109.204.183.274l.089-.065.117-.09-.023-.018a.4.4 0 0 1-.11-.161.7.7 0 0 1-.054-.22v-.01a.7.7 0 0 1 .014-.234.4.4 0 0 1 .08-.179q.056-.069.126-.073h.013a.18.18 0 0 1 .123.05c.045.04.08.09.11.162a.7.7 0 0 1 .054.22v.01a.7.7 0 0 1-.002.17 1.1 1.1 0 0 1 .317-.143 1.3 1.3 0 0 0 .002-.194V3.23a1.2 1.2 0 0 0-.102-.437.8.8 0 0 0-.227-.31.4.4 0 0 0-.268-.102m1.95-.155a.63.63 0 0 0-.394.14.9.9 0 0 0-.287.376 1.2 1.2 0 0 0-.1.51v.015q0 .079.01.152c.114.027.278.074.406.138a1 1 0 0 1-.011-.172.8.8 0 0 1 .058-.278.5.5 0 0 1 .139-.2.26.26 0 0 1 .182-.069.26.26 0 0 1 .178.081c.055.054.094.12.124.21.029.086.042.17.04.27l-.002.012a.8.8 0 0 1-.057.277c-.024.059-.089.106-.122.145.046.016.09.03.146.052a5 5 0 0 1 .248.102 1.2 1.2 0 0 0 .244-.763 1.2 1.2 0 0 0-.11-.495.9.9 0 0 0-.294-.37.64.64 0 0 0-.39-.133z" />
</svg>
)
}
function TerminalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</svg>
)
}
function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<path d="m6 9 6 6 6-6" />
</svg>
)
}
function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
@@ -384,7 +684,13 @@ function CodeBlock({ children }: { children: React.ReactNode }) {
function Story() {
return (
<div className="pt-16 space-y-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="pt-16 space-y-4"
>
<h2 className="text-2xl font-medium">Background</h2>
<div className="space-y-4 text-sm text-white/60">
<p>
@@ -403,13 +709,19 @@ function Story() {
testing different agents, so locking myself to a single harness or model wasn't an option.
</p>
</div>
</div>
</motion.div>
)
}
function FAQ() {
return (
<div className="pt-16 space-y-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="pt-16 space-y-6"
>
<h2 className="text-2xl font-medium">FAQ</h2>
<div className="space-y-6">
<FAQItem question="Is this free?">
@@ -433,7 +745,7 @@ function FAQ() {
</FAQItem>
<FAQItem question="What does Paseo mean?">Stroll, in Spanish. 🚶‍♂️</FAQItem>
</div>
</div>
</motion.div>
)
}