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
View File
@@ -16710,6 +16710,33 @@
"node": ">= 0.6" "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": { "node_modules/freeport-async": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
@@ -20790,6 +20817,21 @@
"node": "*" "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": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -28097,6 +28139,7 @@
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
"@tanstack/react-router": "^1.120.3", "@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3", "@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -597,7 +597,7 @@ export function AgentInputArea({
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false
const handleToggleRealtimeVoice = useCallback(() => { const handleToggleRealtimeVoice = useCallback(() => {
if (!voice || !isConnected) { if (!voice || !isConnected || !agent) {
return return
} }
if (voice.isVoiceSwitching) { if (voice.isVoiceSwitching) {
@@ -614,7 +614,7 @@ export function AgentInputArea({
toast.error(message) toast.error(message)
} }
}) })
}, [agentId, isConnected, serverId, toast, voice]) }, [agent, agentId, isConnected, serverId, toast, voice])
function handleEditQueuedMessage(id: string) { function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id) const item = queuedMessages.find((q) => q.id === id)
@@ -704,7 +704,7 @@ export function AgentInputArea({
const rightContent = ( const rightContent = (
<View style={styles.rightControls}> <View style={styles.rightControls}>
{!isVoiceModeForAgent ? ( {!isVoiceModeForAgent && agent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={handleToggleRealtimeVoice} onPress={handleToggleRealtimeVoice}
+11 -3
View File
@@ -27,6 +27,7 @@ interface AgentListProps {
selectedAgentId?: string selectedAgentId?: string
onAgentSelect?: () => void onAgentSelect?: () => void
listFooterComponent?: ReactElement | null listFooterComponent?: ReactElement | null
showAttentionIndicator?: boolean
} }
interface AgentListSection { interface AgentListSection {
@@ -112,12 +113,14 @@ function SessionRow({
agent, agent,
isMobile, isMobile,
selectedAgentId, selectedAgentId,
showAttentionIndicator,
onPress, onPress,
onLongPress, onLongPress,
}: { }: {
agent: AggregatedAgent agent: AggregatedAgent
isMobile: boolean isMobile: boolean
selectedAgentId?: string selectedAgentId?: string
showAttentionIndicator: boolean
onPress: (agent: AggregatedAgent) => void onPress: (agent: AggregatedAgent) => void
onLongPress: (agent: AggregatedAgent) => void onLongPress: (agent: AggregatedAgent) => void
}) { }) {
@@ -154,7 +157,7 @@ function SessionRow({
{(agent.pendingPermissionCount ?? 0) > 0 ? ( {(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" /> <SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
) : null} ) : null}
{!isMobile && agent.requiresAttention ? ( {!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" /> <SessionBadge label="Attention" tone="danger" />
) : null} ) : null}
</View> </View>
@@ -187,7 +190,7 @@ function SessionRow({
<Text style={styles.columnMetaFixed}>{timeAgo}</Text> <Text style={styles.columnMetaFixed}>{timeAgo}</Text>
</> </>
)} )}
{isMobile && agent.requiresAttention ? ( {isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}> <View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" /> <SessionBadge label="Attention" tone="danger" />
</View> </View>
@@ -200,12 +203,14 @@ function SessionTableSection({
section, section,
isMobile, isMobile,
selectedAgentId, selectedAgentId,
showAttentionIndicator,
onAgentPress, onAgentPress,
onAgentLongPress, onAgentLongPress,
}: { }: {
section: AgentListSection section: AgentListSection
isMobile: boolean isMobile: boolean
selectedAgentId?: string selectedAgentId?: string
showAttentionIndicator: boolean
onAgentPress: (agent: AggregatedAgent) => void onAgentPress: (agent: AggregatedAgent) => void
onAgentLongPress: (agent: AggregatedAgent) => void onAgentLongPress: (agent: AggregatedAgent) => void
}) { }) {
@@ -225,6 +230,7 @@ function SessionTableSection({
agent={agent} agent={agent}
isMobile={isMobile} isMobile={isMobile}
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onPress={onAgentPress} onPress={onAgentPress}
onLongPress={onAgentLongPress} onLongPress={onAgentLongPress}
/> />
@@ -242,6 +248,7 @@ export function AgentList({
selectedAgentId, selectedAgentId,
onAgentSelect, onAgentSelect,
listFooterComponent, listFooterComponent,
showAttentionIndicator = true,
}: AgentListProps) { }: AgentListProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const pathname = usePathname() const pathname = usePathname()
@@ -325,11 +332,12 @@ export function AgentList({
section={section} section={section}
isMobile={isMobile} isMobile={isMobile}
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onAgentPress={handleAgentPress} onAgentPress={handleAgentPress}
onAgentLongPress={handleAgentLongPress} onAgentLongPress={handleAgentLongPress}
/> />
), ),
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId] [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator]
) )
const keyExtractor = useCallback((section: AgentListSection) => section.key, []) const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
@@ -24,7 +24,7 @@ import { router, usePathname } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler' import { type GestureType } from 'react-native-gesture-handler'
import * as Clipboard from 'expo-clipboard' 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 { NestableScrollContainer } from 'react-native-draggable-flatlist'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import type { DraggableListDragHandleProps } from './draggable-list.types' import type { DraggableListDragHandleProps } from './draggable-list.types'
@@ -43,7 +43,6 @@ import {
} from '@/hooks/use-sidebar-workspaces-list' } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { formatTimeAgo } from '@/utils/time'
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -126,12 +125,6 @@ interface WorkspaceRowInnerProps {
onCopyPath?: () => void onCopyPath?: () => void
} }
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
if (!workspace.activityAt) {
return null
}
return formatTimeAgo(workspace.activityAt)
}
function resolveStatusDotColor(input: { function resolveStatusDotColor(input: {
theme: ReturnType<typeof useUnistyles>['theme'] theme: ReturnType<typeof useUnistyles>['theme']
@@ -620,7 +613,8 @@ function WorkspaceRowInner({
onCopyPath, onCopyPath,
}: WorkspaceRowInnerProps) { }: WorkspaceRowInnerProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) const [isHovered, setIsHovered] = useState(false)
const isMobile = Platform.OS !== 'web'
const interaction = useLongPressDragInteraction({ const interaction = useLongPressDragInteraction({
drag, drag,
menuController, menuController,
@@ -636,14 +630,18 @@ function WorkspaceRowInner({
}, [interaction.didLongPressRef, onPress]) }, [interaction.didLongPressRef, onPress])
return ( return (
<View style={styles.workspaceRowContainer}> <View
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<Pressable <Pressable
disabled={isArchiving} disabled={isArchiving}
style={({ pressed, hovered = false }) => [ style={({ pressed }) => [
styles.workspaceRow, styles.workspaceRow,
isDragging && styles.workspaceRowDragging, isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected, selected && styles.sidebarRowSelected,
hovered && styles.workspaceRowHovered, isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed, pressed && styles.workspaceRowPressed,
]} ]}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
@@ -659,41 +657,43 @@ function WorkspaceRowInner({
style={styles.workspaceRowLeft} style={styles.workspaceRowLeft}
> >
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} /> <WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<Text style={styles.workspaceBranchText} numberOfLines={1}> <Text
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]}
numberOfLines={1}
>
{workspace.name} {workspace.name}
</Text> </Text>
</View> </View>
<View style={styles.workspaceRowRight}> <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 ? ( {showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}> <View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text> <Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View> </View>
) : null} ) : null}
{onArchive ? ( {onArchive && (isHovered || isMobile) ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
hitSlop={8} hitSlop={8}
style={styles.kebabButton} style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Workspace actions" accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} 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> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}> <DropdownMenuContent align="end" width={200}>
{onCopyPath ? ( {onCopyPath ? (
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`} testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath} onSelect={onCopyPath}
> >
Copy path Copy path
@@ -701,15 +701,20 @@ function WorkspaceRowInner({
) : null} ) : null}
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`} testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
status={archiveStatus} status={archiveStatus}
pendingLabel={archivePendingLabel} pendingLabel={archivePendingLabel}
destructive
onSelect={onArchive} onSelect={onArchive}
> >
{archiveLabel ?? 'Archive'} {archiveLabel ?? 'Archive'}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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} ) : null}
</View> </View>
</Pressable> </Pressable>
@@ -1726,10 +1731,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
workspaceCreatedAtText: { workspaceBranchTextHovered: {
color: theme.colors.foregroundMuted, opacity: 1,
fontSize: theme.fontSize.xs,
flexShrink: 0,
}, },
diffStatRow: { diffStatRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -1752,6 +1755,9 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: 4, borderRadius: 4,
marginLeft: 2, marginLeft: 2,
}, },
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
shortcutBadge: { shortcutBadge: {
minWidth: 18, minWidth: 18,
height: 18, height: 18,
@@ -8,6 +8,7 @@ import {
useState, useState,
type PropsWithChildren, type PropsWithChildren,
type ReactElement, type ReactElement,
type ReactNode,
} from "react"; } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -184,8 +185,9 @@ type TriggerStyleProp =
| StyleProp<ViewStyle> | StyleProp<ViewStyle>
| ((state: TriggerState) => StyleProp<ViewStyle>); | ((state: TriggerState) => StyleProp<ViewStyle>);
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style"> { interface DropdownMenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
style?: TriggerStyleProp; style?: TriggerStyleProp;
children: ReactNode | ((state: TriggerState) => ReactNode);
} }
export function DropdownMenuTrigger({ export function DropdownMenuTrigger({
@@ -193,7 +195,7 @@ export function DropdownMenuTrigger({
disabled, disabled,
style, style,
...props ...props
}: PropsWithChildren<DropdownMenuTriggerProps>): ReactElement { }: DropdownMenuTriggerProps): ReactElement {
const ctx = useDropdownMenuContext("DropdownMenuTrigger"); const ctx = useDropdownMenuContext("DropdownMenuTrigger");
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
@@ -215,7 +217,10 @@ export function DropdownMenuTrigger({
return style; return style;
}} }}
> >
{children} {({ pressed, hovered = false }) => {
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
return typeof children === "function" ? children(state) : children;
}}
</Pressable> </Pressable>
); );
} }
@@ -52,6 +52,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
showCheckoutInfo={false} showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
showAttentionIndicator={false}
/> />
</View> </View>
); );
@@ -291,7 +291,12 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
await firstTurn.next(); await firstTurn.next();
const secondTurnPromise = collectUntilTerminal(session.stream("second prompt")); 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?.(); sdkMocks.releaseOldAssistant?.();
const secondTurnEvents = await secondTurnPromise; const secondTurnEvents = await secondTurnPromise;
@@ -309,6 +314,93 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
await session.close(); 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 () => { test("ignores stale task-notification assistant/result events queued before the current prompt", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
@@ -1038,7 +1038,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
let capturedQuestion: AgentPermissionRequest | null = null; let capturedQuestion: AgentPermissionRequest | null = null;
let sawResolvedAllow = false; let sawResolvedAllow = false;
let sawDone = false; let assistantText = "";
for await (const event of session.stream(prompt)) { for await (const event of session.stream(prompt)) {
if ( if (
@@ -1071,10 +1071,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
if ( if (
event.type === "timeline" && event.type === "timeline" &&
event.item.type === "assistant_message" && event.item.type === "assistant_message"
event.item.text.includes("QUESTION_FLOW_DONE")
) { ) {
sawDone = true; assistantText += event.item.text;
} }
if (event.type === "turn_completed" || event.type === "turn_failed") { if (event.type === "turn_completed" || event.type === "turn_failed") {
@@ -1085,7 +1084,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
expect(capturedQuestion).not.toBeNull(); expect(capturedQuestion).not.toBeNull();
expect(sawResolvedAllow).toBe(true); expect(sawResolvedAllow).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0); expect(session.getPendingPermissions()).toHaveLength(0);
expect(sawDone).toBe(true); expect(assistantText).toContain("QUESTION_FLOW_DONE");
} finally { } finally {
await closeSessionAndCleanup(session, cwd); await closeSessionAndCleanup(session, cwd);
} }
@@ -1418,12 +1418,12 @@ export class ClaudeAgentClient implements AgentClient {
if (this.claudePath) { if (this.claudePath) {
try { try {
const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim(); 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 { } catch {
this.logger.info({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)"); this.logger.trace({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)");
} }
} else { } 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) => { 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; this.queryPumpPromise = pump;
@@ -2886,7 +2886,7 @@ class ClaudeAgentSession implements AgentSession {
try { try {
q = await this.ensureQuery(); q = await this.ensureQuery();
} catch (error) { } 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(); await this.waitForLiveHistoryPoll();
continue; continue;
} }
@@ -2894,12 +2894,23 @@ class ClaudeAgentSession implements AgentSession {
let next: IteratorResult<SDKMessage, void>; let next: IteratorResult<SDKMessage, void>;
try { try {
next = await q.next(); next = await q.next();
this.logger.info( this.logger.trace(
{ claudeSessionId: this.claudeSessionId, next }, { claudeSessionId: this.claudeSessionId, next },
"Claude query pump raw next()" "Claude query pump raw next()"
); );
} catch (error) { } 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()) { for (const run of this.runTracker.listActiveRuns()) {
this.failRun( this.failRun(
run, run,
@@ -2917,6 +2928,21 @@ class ClaudeAgentSession implements AgentSession {
} }
if (next.done) { 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( this.logger.trace(
{ {
claudeSessionId: this.claudeSessionId, claudeSessionId: this.claudeSessionId,
@@ -2945,6 +2971,22 @@ class ClaudeAgentSession implements AgentSession {
continue; 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)) { if (await this.handleMissingResumedConversation(sdkMessage, q)) {
continue; continue;
} }
@@ -2952,7 +2994,7 @@ class ClaudeAgentSession implements AgentSession {
try { try {
this.routeSdkMessageFromPump(sdkMessage); this.routeSdkMessageFromPump(sdkMessage);
} catch (error) { } 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> { private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query; const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") { if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.debug("interruptActiveTurn: no query to interrupt"); this.logger.trace("interruptActiveTurn: no query to interrupt");
return; return;
} }
try { try {
this.logger.debug("interruptActiveTurn: calling query.interrupt()..."); this.logger.trace("interruptActiveTurn: calling query.interrupt()...");
const t0 = Date.now(); const t0 = Date.now();
await queryToInterrupt.interrupt(); 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). // After interrupt(), the query iterator is done (returns done: true).
// Clear it so ensureQuery() creates a fresh query for the next turn. // 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. // Also end the input stream and call return() to clean up the SDK process.
this.input?.end(); this.input?.end();
this.logger.debug("interruptActiveTurn: calling query.return()..."); this.logger.trace("interruptActiveTurn: calling query.return()...");
const t1 = Date.now(); const t1 = Date.now();
await queryToInterrupt.return?.(); 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.query = null;
this.input = null; this.input = null;
this.queryRestartNeeded = false; this.queryRestartNeeded = false;
+1
View File
@@ -15,6 +15,7 @@
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
"@tanstack/react-router": "^1.120.3", "@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3", "@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
+405 -93
View File
@@ -1,11 +1,65 @@
import * as React from 'react' import * as React from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { motion, AnimatePresence } from 'framer-motion'
import { CursorFieldProvider } from '~/components/butterfly' import { CursorFieldProvider } from '~/components/butterfly'
import websitePackage from '../../package.json' import websitePackage from '../../package.json'
import '~/styles.css' import '~/styles.css'
const desktopVersion = websitePackage.version 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('/')({ export const Route = createFileRoute('/')({
head: () => ({ head: () => ({
@@ -36,7 +90,12 @@ function Home() {
</div> </div>
{/* Mockup - inside hero so it's above the gradient, positioned to overflow into black section */} {/* 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"> <div className="max-w-6xl mx-auto">
<img <img
src="/paseo-mockup.png" src="/paseo-mockup.png"
@@ -44,7 +103,7 @@ function Home() {
className="w-full rounded-lg shadow-2xl" className="w-full rounded-lg shadow-2xl"
/> />
</div> </div>
</div> </motion.div>
</div> </div>
{/* Content section */} {/* Content section */}
@@ -114,16 +173,53 @@ function Nav() {
function Hero() { function Hero() {
return ( return (
<div className="space-y-6"> <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 One interface for all your coding agents
</h1> </motion.h1>
<p className="text-white/70 text-lg leading-relaxed"> <motion.p
Run Claude Code, Codex, and OpenCode on your machine. Connect from your phone or desktop. initial={{ opacity: 0, y: 20 }}
</p> 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> </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 }) { function Differentiator({ title, description }: { title: string; description: string }) {
return ( return (
<div> <div>
@@ -137,108 +233,246 @@ function Features() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<Feature {[
title="Self-hosted" { title: 'Self-hosted', description: 'The daemon runs on your laptop, home server, or VPS. Allowing you to take full advantage of your dev environment.' },
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.' },
<Feature { title: 'First-class voice', description: 'Real-time voice conversations and dictation. Talk to your agent, hear responses, and orchestrate work hands-free.' },
title="Multi-provider" { title: 'Optional relay', description: 'Use the hosted end-to-end encrypted relay for remote access, or connect directly over your network.' },
description="Works with existing agent harnesses like Claude Code, Codex, and OpenCode from one interface." { 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.' },
<Feature { title: 'Open source', description: 'Free and open source. Run it yourself, fork it, contribute.' },
title="Multi-host" ].map((feature, i) => (
description="Connect to multiple daemons and see all your agents in one place." <motion.div
/> key={feature.title}
<Feature initial={{ opacity: 0, y: 16 }}
title="First-class voice" whileInView={{ opacity: 1, y: 0 }}
description="Real-time voice conversations and dictation. Talk to your agent, hear responses, and orchestrate work hands-free." viewport={{ once: true, margin: '-40px' }}
/> transition={{ duration: 0.4, delay: i * 0.06, ease: 'easeOut' }}
<Feature className="space-y-1"
title="Optional relay" >
description="Use the hosted end-to-end encrypted relay for remote access, or connect directly over your network." <p className="font-medium text-base">{feature.title}</p>
/> <p className="text-sm text-white/60">{feature.description}</p>
<Feature </motion.div>
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."
/>
</div> </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() { function GetStarted() {
return ( return (
<div className="pt-10 space-y-6"> <motion.div
<Step number={1}> initial={{ opacity: 0, y: 20 }}
<p className="text-sm text-white/70">Install the daemon</p> animate={{ opacity: 1, y: 0 }}
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock> transition={{ duration: 0.6, delay: 0.4, ease: 'easeOut' }}
</Step> className="pt-10"
<Step number={2}> >
<p className="text-sm text-white/70">Download any app</p> <div className="flex flex-row flex-wrap gap-3">
<div className="flex flex-row gap-3"> <DownloadButton />
<a <a
href={macDownloadHref} href="https://app.paseo.sh"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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" /> <GlobeIcon className="h-4 w-4" />
Download for Mac Launch Web App
</a> </a>
<a <a
href="https://app.paseo.sh" href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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"
<GlobeIcon className="h-4 w-4" /> >
Launch Web App <AppleIcon className="h-5 w-5" />
</a> </a>
<span className="relative group inline-flex items-center justify-center rounded-lg border border-white/10 px-3 py-2 text-white/40 cursor-default"> <span className="relative group inline-flex items-center justify-center rounded-lg border border-white/10 px-3 py-2 text-white/40 cursor-default">
<AppleIcon className="h-5 w-5" /> <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"> <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 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>
</span> </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> </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> </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>) { function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1408 1664" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
aria-hidden="true" aria-hidden="true"
{...props} {...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> </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>) { function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
@@ -384,7 +684,13 @@ function CodeBlock({ children }: { children: React.ReactNode }) {
function Story() { function Story() {
return ( 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> <h2 className="text-2xl font-medium">Background</h2>
<div className="space-y-4 text-sm text-white/60"> <div className="space-y-4 text-sm text-white/60">
<p> <p>
@@ -403,13 +709,19 @@ function Story() {
testing different agents, so locking myself to a single harness or model wasn't an option. testing different agents, so locking myself to a single harness or model wasn't an option.
</p> </p>
</div> </div>
</div> </motion.div>
) )
} }
function FAQ() { function FAQ() {
return ( 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> <h2 className="text-2xl font-medium">FAQ</h2>
<div className="space-y-6"> <div className="space-y-6">
<FAQItem question="Is this free?"> <FAQItem question="Is this free?">
@@ -433,7 +745,7 @@ function FAQ() {
</FAQItem> </FAQItem>
<FAQItem question="What does Paseo mean?">Stroll, in Spanish. 🚶‍♂️</FAQItem> <FAQItem question="What does Paseo mean?">Stroll, in Spanish. 🚶‍♂️</FAQItem>
</div> </div>
</div> </motion.div>
) )
} }