mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: add multi-platform downloads and improve homepage animations
This commit is contained in:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
showCheckoutInfo={false}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
showAttentionIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user