diff --git a/RUNTIME_SIMPLIFICATION_PLAN.md b/RUNTIME_SIMPLIFICATION_PLAN.md deleted file mode 100644 index 2d0e591c6..000000000 --- a/RUNTIME_SIMPLIFICATION_PLAN.md +++ /dev/null @@ -1,81 +0,0 @@ -# Runtime Simplification Plan - -## Goal - -Remove the managed runtime install/copy layer. The desktop app must run the bundled runtime in place from the application resources/install directory on macOS, Windows, and Linux. - -This is a simplification task. If a change preserves the old install-manager shape under a new name, it fails the goal. - -## Hard Requirements - -1. The bundled runtime is read-only. - The desktop app must not copy the bundled runtime to app data, temp, cache, or any other writable directory. - -2. The app must execute Node/CLI/server entrypoints directly from the bundled runtime root. - On macOS this means inside `Paseo.app/Contents/Resources/...`. - On Windows and Linux this means inside the installed app resources directory. - -3. There is exactly one runtime per installed app. - Remove versioned installed runtime directories like `runtime/` from the runtime execution path. - -4. The CLI shim may point into the installed app bundle/directory. - Do not keep an extra installed runtime tree just to preserve a stable shim target across updates. - -5. All mutable state remains outside the bundled runtime. - Logs, sockets/pipes, PID files, daemon state, `PASEO_HOME`, and any other writable files must continue to live in managed home / app data locations. - -6. Runtime discovery must stay cross-platform. - The implementation must resolve the bundled runtime/resources path on macOS, Windows, and Linux using the app install/resources directory, not hardcoded `.app` assumptions. - -7. Remove dead machinery, do not leave adapters behind. - If install/copy/versioned-runtime code becomes unused, delete it instead of keeping fallback paths "just in case". - -## Non-Goals - -1. Do not change how the runtime is built into the desktop app bundle in this task. - This task is about runtime execution and path management, not bundling format. - -2. Do not reintroduce a second runtime location for migration compatibility. - It is acceptable if older installed clients do not migrate cleanly. - -3. Do not add feature flags, env-guarded fallback paths, or compatibility shims unless absolutely required by a real platform constraint proven in code. - -## Concrete Implementation Direction - -1. Treat `bundled_runtime_root(app)` plus `current-runtime.json` as the runtime source of truth. - -2. Replace `paths.runtime_root` usage for runtime execution with the bundled runtime root selected by `current-runtime.json`. - -3. Delete `install_runtime_if_needed(...)` and related copy/install staging logic if nothing else still needs it. - -4. Rework CLI shim generation so the inner launcher points at the bundled runtime's Node + CLI entrypoint directly, while still keeping mutable state (`PASEO_HOME`) outside the runtime. - -5. Simplify `ManagedPaths` and related structs if `runtime_root` and `stable_runtime_root` no longer need separate installed-runtime semantics. - -6. Keep diagnostics/status reporting accurate. - If the app reports bundled vs installed runtime roots today, update that output to reflect the new single-runtime model. - -## Acceptance Criteria - -1. No code path copies the bundled runtime tree into app data before launching the daemon or CLI. - -2. No code path depends on a versioned installed runtime directory for execution. - -3. The CLI shim and daemon launch path both resolve to bundled runtime executables/resources. - -4. Typecheck passes. - -5. Relevant desktop/runtime tests pass, updated to reflect the new direct-from-bundle model. - -6. The resulting implementation is materially simpler: - fewer runtime path concepts, fewer staging/install branches, fewer indirections. - -## Review Bar - -The implementation should be rejected if: - -- it still copies the runtime anywhere before execution -- it keeps runtime version directories in the execution path -- it preserves the stable installed runtime launcher concept without a hard platform reason -- it adds migration complexity for old installs -- it introduces new fallback branches instead of removing obsolete ones diff --git a/package-lock.json b/package-lock.json index 5c2ef811c..758152811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7731,7 +7731,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7745,7 +7744,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7759,7 +7757,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7773,7 +7770,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7787,7 +7783,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7801,7 +7796,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7815,7 +7809,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7829,7 +7822,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7843,7 +7835,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7857,7 +7848,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7871,7 +7861,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7885,7 +7874,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7899,7 +7887,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7913,7 +7900,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7927,7 +7913,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7941,7 +7926,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7955,7 +7939,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7969,7 +7952,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7983,7 +7965,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7997,7 +7978,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8011,7 +7991,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8025,7 +8004,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8039,7 +8017,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8053,7 +8030,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8067,7 +8043,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9511,7 +9486,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -9521,7 +9495,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -12535,7 +12509,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -16346,6 +16319,22 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -17197,7 +17186,7 @@ "version": "16.8.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -18611,7 +18600,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -23341,7 +23330,6 @@ "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -23908,7 +23896,6 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -26596,7 +26583,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -26714,7 +26700,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -27498,7 +27483,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -27769,6 +27753,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "fast-uri": "^3.1.0", "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index cde799af8..830a86455 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -6,187 +6,126 @@ import { RefreshControl, FlatList, type ListRenderItem, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useCallback, useMemo, useState, type ReactElement } from "react"; -import { router, usePathname, type Href } from "expo-router"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { formatTimeAgo } from "@/utils/time"; -import { shortenPath } from "@/utils/shorten-path"; -import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useSessionStore } from "@/stores/session-store"; -import { AgentStatusDot } from "@/components/agent-status-dot"; -import { - buildAgentNavigationKey, - startNavigationTiming, -} from "@/utils/navigation-timing"; -import { buildHostWorkspaceAgentRoute } from "@/utils/host-routes"; +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useCallback, useMemo, useState, type ReactElement } from 'react' +import { router, usePathname, type Href } from 'expo-router' +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' +import { formatTimeAgo } from '@/utils/time' +import { shortenPath } from '@/utils/shorten-path' +import { type AggregatedAgent } from '@/hooks/use-aggregated-agents' +import { useSessionStore } from '@/stores/session-store' +import { AgentStatusDot } from '@/components/agent-status-dot' +import { buildAgentNavigationKey, startNavigationTiming } from '@/utils/navigation-timing' +import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes' interface AgentListProps { - agents: AggregatedAgent[]; - showCheckoutInfo?: boolean; - isRefreshing?: boolean; - onRefresh?: () => void; - selectedAgentId?: string; - onAgentSelect?: () => void; - listFooterComponent?: ReactElement | null; + agents: AggregatedAgent[] + showCheckoutInfo?: boolean + isRefreshing?: boolean + onRefresh?: () => void + selectedAgentId?: string + onAgentSelect?: () => void + listFooterComponent?: ReactElement | null } interface AgentListSection { - key: string; - title: string; - data: AggregatedAgent[]; + key: string + title: string + data: AggregatedAgent[] } -type SessionColumnKey = "session" | "project" | "host" | "status" | "updated"; - -interface SessionColumnDefinition { - key: SessionColumnKey; - label: string; - flex: number; - align?: "left" | "right"; - mobile?: boolean; - requiresMultiHost?: boolean; -} - -const SESSION_COLUMNS: SessionColumnDefinition[] = [ - { key: "session", label: "Session", flex: 2.3, mobile: true }, - { key: "project", label: "Project", flex: 2.6 }, - { key: "host", label: "Host", flex: 1.2, requiresMultiHost: true }, - { key: "status", label: "Status", flex: 1.2, mobile: true }, - { key: "updated", label: "Updated", flex: 1, align: "right", mobile: true }, -]; - function deriveDateSectionLabel(lastActivityAt: Date): string { - const now = new Date(); - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000); + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000) const activityStart = new Date( lastActivityAt.getFullYear(), lastActivityAt.getMonth(), lastActivityAt.getDate() - ); + ) if (activityStart.getTime() >= todayStart.getTime()) { - return "Today"; + return 'Today' } if (activityStart.getTime() >= yesterdayStart.getTime()) { - return "Yesterday"; + return 'Yesterday' } - const diffTime = todayStart.getTime() - activityStart.getTime(); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + const diffTime = todayStart.getTime() - activityStart.getTime() + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) if (diffDays <= 7) { - return "This week"; + return 'This week' } if (diffDays <= 30) { - return "This month"; + return 'This month' } - return "Older"; + return 'Older' } -function formatStatusLabel(status: AggregatedAgent["status"]): string { +function formatStatusLabel(status: AggregatedAgent['status']): string { switch (status) { - case "initializing": - return "Starting"; - case "idle": - return "Idle"; - case "running": - return "Running"; - case "error": - return "Error"; - case "closed": - return "Closed"; + case 'initializing': + return 'Starting' + case 'idle': + return 'Idle' + case 'running': + return 'Running' + case 'error': + return 'Error' + case 'closed': + return 'Closed' default: - return status; + return status } } -function getVisibleColumns(input: { - isMobile: boolean; - showHostColumn: boolean; -}): SessionColumnDefinition[] { - return SESSION_COLUMNS.filter((column) => { - if (!input.showHostColumn && column.requiresMultiHost) { - return false; - } - if (input.isMobile && !column.mobile) { - return false; - } - return true; - }); -} - -function SessionCell({ - align = "left", - flex, - children, -}: { - align?: "left" | "right"; - flex: number; - children: ReactElement; -}) { - return ( - - {children} - - ); -} - function SessionBadge({ label, - tone = "neutral", + tone = 'neutral', }: { - label: string; - tone?: "neutral" | "warning" | "danger"; + label: string + tone?: 'neutral' | 'warning' | 'danger' }) { return ( {label} - ); + ) } -function SessionTableRow({ +function SessionRow({ agent, - columns, isMobile, selectedAgentId, onPress, onLongPress, }: { - agent: AggregatedAgent; - columns: SessionColumnDefinition[]; - isMobile: boolean; - selectedAgentId?: string; - onPress: (agent: AggregatedAgent) => void; - onLongPress: (agent: AggregatedAgent) => void; + agent: AggregatedAgent + isMobile: boolean + selectedAgentId?: string + onPress: (agent: AggregatedAgent) => void + onLongPress: (agent: AggregatedAgent) => void }) { - const timeAgo = formatTimeAgo(agent.lastActivityAt); - const agentKey = `${agent.serverId}:${agent.id}`; - const isSelected = selectedAgentId === agentKey; - const statusLabel = formatStatusLabel(agent.status); - const projectPath = shortenPath(agent.cwd); + const timeAgo = formatTimeAgo(agent.lastActivityAt) + const agentKey = `${agent.serverId}:${agent.id}` + const isSelected = selectedAgentId === agentKey + const statusLabel = formatStatusLabel(agent.status) + const projectPath = shortenPath(agent.cwd) return ( onLongPress(agent)} testID={`agent-row-${agent.serverId}-${agent.id}`} > - {({ hovered }) => ( - - {columns.map((column) => { - if (column.key === "session") { - return ( - - - - - {agent.title || "New session"} - - {agent.archivedAt ? : null} - {(agent.pendingPermissionCount ?? 0) > 0 ? ( - - ) : null} - - {isMobile ? ( - - - {projectPath} - - · - {statusLabel} - {agent.serverLabel ? ( - <> - · - - {agent.serverLabel} - - - ) : null} - - ) : ( - - {agent.requiresAttention ? ( - - ) : null} - - )} - - - ); - } - - if (column.key === "project") { - return ( - - - - {projectPath} - - - {agent.provider} - - - - ); - } - - if (column.key === "host") { - return ( - - - {agent.serverLabel} - - - ); - } - - if (column.key === "status") { - return ( - - - - - {statusLabel} - - - - ); - } - - return ( - - - {timeAgo} - - - ); - })} + + + + + + + {agent.title || 'New session'} + + {agent.archivedAt ? : null} + {(agent.pendingPermissionCount ?? 0) > 0 ? ( + + ) : null} + {!isMobile && agent.requiresAttention ? ( + + ) : null} - )} + + + {projectPath} + + · + {statusLabel} + · + {timeAgo} + {agent.serverLabel ? ( + <> + · + + {agent.serverLabel} + + + ) : null} + + + {isMobile && agent.requiresAttention ? ( + + + + ) : null} - ); + ) } function SessionTableSection({ section, - columns, isMobile, selectedAgentId, onAgentPress, onAgentLongPress, }: { - section: AgentListSection; - columns: SessionColumnDefinition[]; - isMobile: boolean; - selectedAgentId?: string; - onAgentPress: (agent: AggregatedAgent) => void; - onAgentLongPress: (agent: AggregatedAgent) => void; + section: AgentListSection + isMobile: boolean + selectedAgentId?: string + onAgentPress: (agent: AggregatedAgent) => void + onAgentLongPress: (agent: AggregatedAgent) => void }) { return ( @@ -330,31 +205,14 @@ function SessionTableSection({ - - - {columns.map((column) => ( - - - {column.label} - - - ))} - - + {section.data.map((agent, index) => ( 0 ? styles.rowDivider : undefined} > - - ); + ) } export function AgentList({ @@ -375,110 +233,96 @@ export function AgentList({ onAgentSelect, listFooterComponent, }: AgentListProps) { - const { theme } = useUnistyles(); - const pathname = usePathname(); - const insets = useSafeAreaInsets(); - const [actionAgent, setActionAgent] = useState(null); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const { theme } = useUnistyles() + const pathname = usePathname() + const insets = useSafeAreaInsets() + const [actionAgent, setActionAgent] = useState(null) + const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const actionClient = useSessionStore((state) => - actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null - ); + actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null + ) - const isActionSheetVisible = actionAgent !== null; - const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); - const showHostColumn = useMemo( - () => new Set(agents.map((agent) => agent.serverId)).size > 1, - [agents] - ); - const columns = useMemo( - () => getVisibleColumns({ isMobile, showHostColumn }), - [isMobile, showHostColumn] - ); + const isActionSheetVisible = actionAgent !== null + const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient) const handleAgentPress = useCallback( (agent: AggregatedAgent) => { if (isActionSheetVisible) { - return; + return } - const serverId = agent.serverId; - const agentId = agent.id; - const navigationKey = buildAgentNavigationKey(serverId, agentId); + const serverId = agent.serverId + const agentId = agent.id + const navigationKey = buildAgentNavigationKey(serverId, agentId) startNavigationTiming(navigationKey, { - from: "home", - to: "agent", + from: 'home', + to: 'agent', params: { serverId, agentId }, - }); + }) - const shouldReplace = pathname.startsWith("/h/"); - const navigate = shouldReplace ? router.replace : router.push; + const shouldReplace = pathname.startsWith('/h/') + const navigate = shouldReplace ? router.replace : router.push - onAgentSelect?.(); + onAgentSelect?.() - const route: Href = buildHostWorkspaceAgentRoute( - serverId, - agent.cwd, - agentId - ) as Href; - navigate(route); + const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href + navigate(route) }, [isActionSheetVisible, pathname, onAgentSelect] - ); + ) const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { - setActionAgent(agent); - }, []); + setActionAgent(agent) + }, []) const handleCloseActionSheet = useCallback(() => { - setActionAgent(null); - }, []); + setActionAgent(null) + }, []) const handleArchiveAgent = useCallback(() => { if (!actionAgent || !actionClient) { - return; + return } - void actionClient.archiveAgent(actionAgent.id); - setActionAgent(null); - }, [actionAgent, actionClient]); + void actionClient.archiveAgent(actionAgent.id) + setActionAgent(null) + }, [actionAgent, actionClient]) const sections = useMemo((): AgentListSection[] => { - const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; - const buckets = new Map(); + const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const + const buckets = new Map() for (const agent of agents) { - const label = deriveDateSectionLabel(agent.lastActivityAt); - const existing = buckets.get(label) ?? []; - existing.push(agent); - buckets.set(label, existing); + const label = deriveDateSectionLabel(agent.lastActivityAt) + const existing = buckets.get(label) ?? [] + existing.push(agent) + buckets.set(label, existing) } - const result: AgentListSection[] = []; + const result: AgentListSection[] = [] for (const label of order) { - const data = buckets.get(label); + const data = buckets.get(label) if (!data || data.length === 0) { - continue; + continue } - result.push({ key: `date:${label}`, title: label, data }); + result.push({ key: `date:${label}`, title: label, data }) } - return result; - }, [agents]); + return result + }, [agents]) const renderSection: ListRenderItem = useCallback( ({ item: section }) => ( ), - [columns, handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId] - ); + [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId] + ) - const keyExtractor = useCallback((section: AgentListSection) => section.key, []); + const keyExtractor = useCallback((section: AgentListSection) => section.key, []) return ( <> @@ -510,10 +354,7 @@ export function AgentList({ onRequestClose={handleCloseActionSheet} > - + - {isActionDaemonUnavailable ? "Host offline" : "Archive this session?"} + {isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'} - ); + ) } const styles = StyleSheet.create((theme) => ({ @@ -573,17 +414,17 @@ const styles = StyleSheet.create((theme) => ({ marginTop: theme.spacing[2], }, sectionHeading: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[3], paddingHorizontal: theme.spacing[1], marginBottom: theme.spacing[2], }, sectionTitle: { fontSize: theme.fontSize.sm, - fontWeight: "600", + fontWeight: '600', color: theme.colors.foregroundMuted, - textTransform: "uppercase", + textTransform: 'uppercase', letterSpacing: 0.6, }, sectionLine: { @@ -591,100 +432,66 @@ const styles = StyleSheet.create((theme) => ({ height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.surface2, }, - tableCard: { - overflow: "hidden", - borderRadius: theme.borderRadius.xl, - borderWidth: StyleSheet.hairlineWidth, - borderColor: theme.colors.surface2, - backgroundColor: theme.colors.surface1, - }, - tableHeader: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: { - xs: theme.spacing[3], - md: theme.spacing[4], - }, - paddingVertical: theme.spacing[2], - backgroundColor: theme.colors.surface0, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: theme.colors.surface2, - }, - columnLabel: { - fontSize: theme.fontSize.xs, - fontWeight: "600", - color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.6, - }, - columnLabelRight: { - textAlign: "right", + listCard: { + overflow: 'hidden', + borderRadius: theme.borderRadius.lg, }, rowDivider: { borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: theme.colors.surface2, + borderTopColor: theme.colors.border, }, row: { - paddingHorizontal: { - xs: theme.spacing[3], - md: theme.spacing[4], - }, - paddingVertical: { - xs: theme.spacing[2], - md: theme.spacing[3], - }, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[1], }, - rowInner: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], + rowLeading: { + marginRight: theme.spacing[3], + }, + rowContent: { + flex: 1, + minWidth: 0, + }, + rowTitleRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing[2], + }, + rowMetaRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing[1], + marginTop: 2, + }, + rowTrailing: { + marginLeft: theme.spacing[2], }, rowSelected: { backgroundColor: theme.colors.surface2, }, rowHovered: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface1, }, rowPressed: { backgroundColor: theme.colors.surface2, }, - cell: { - minWidth: 0, - }, - cellLeft: { - alignItems: "flex-start", - }, - cellRight: { - alignItems: "flex-end", - }, - primaryCell: { - width: "100%", - gap: theme.spacing[1], - }, - sessionTitleRow: { - flexDirection: "row", - alignItems: "center", - flexWrap: "wrap", - gap: theme.spacing[2], - }, sessionTitle: { flexShrink: 1, - fontSize: theme.fontSize.base, - fontWeight: "500", + fontSize: theme.fontSize.sm, + fontWeight: '500', color: theme.colors.foreground, opacity: 0.86, }, sessionTitleHighlighted: { opacity: 1, }, - sessionMetaRow: { - flexDirection: "row", - alignItems: "center", - flexWrap: "wrap", - gap: theme.spacing[1], - }, sessionMetaText: { - maxWidth: "100%", + maxWidth: '100%', fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, @@ -693,42 +500,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, opacity: 0.7, }, - secondaryBadgeRow: { - minHeight: theme.spacing[6], - justifyContent: "center", - }, - projectCell: { - width: "100%", - gap: theme.spacing[1], - }, - projectPath: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - projectProvider: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.6, - }, - hostText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - statusCell: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - statusText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - updatedText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "right", - }, badge: { paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], @@ -736,16 +507,16 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, badgeWarning: { - backgroundColor: "rgba(245, 158, 11, 0.12)", + backgroundColor: 'rgba(245, 158, 11, 0.12)', }, badgeDanger: { - backgroundColor: "rgba(239, 68, 68, 0.14)", + backgroundColor: 'rgba(239, 68, 68, 0.14)', }, badgeText: { fontSize: theme.fontSize.xs, - fontWeight: "600", + fontWeight: '600', color: theme.colors.foregroundMuted, - textTransform: "uppercase", + textTransform: 'uppercase', letterSpacing: 0.4, }, badgeTextWarning: { @@ -756,26 +527,26 @@ const styles = StyleSheet.create((theme) => ({ }, sheetOverlay: { flex: 1, - justifyContent: "flex-end", + justifyContent: 'flex-end', }, sheetBackdrop: { - position: "absolute", + position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, - backgroundColor: "rgba(0,0,0,0.35)", + backgroundColor: 'rgba(0,0,0,0.35)', }, sheetContainer: { backgroundColor: theme.colors.surface2, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], + borderTopLeftRadius: theme.borderRadius['2xl'], + borderTopRightRadius: theme.borderRadius['2xl'], paddingHorizontal: theme.spacing[6], paddingTop: theme.spacing[4], gap: theme.spacing[4], }, sheetHandle: { - alignSelf: "center", + alignSelf: 'center', width: 40, height: 4, borderRadius: theme.borderRadius.full, @@ -786,18 +557,18 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, - textAlign: "center", + textAlign: 'center', }, sheetButtonRow: { - flexDirection: "row", + flexDirection: 'row', gap: theme.spacing[3], }, sheetButton: { flex: 1, borderRadius: theme.borderRadius.lg, paddingVertical: theme.spacing[4], - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, sheetArchiveButton: { backgroundColor: theme.colors.primary, @@ -818,4 +589,4 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.semibold, fontSize: theme.fontSize.base, }, -})); +})) diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 73c1e5a3f..ccd6a9c5c 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { View, Text, Platform, Pressable } from 'react-native' import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native' @@ -10,6 +10,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { Combobox, type ComboboxOption } from '@/components/ui/combobox' import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet' import type { AgentMode, @@ -90,7 +91,12 @@ function ControlledStatusBar({ const { theme } = useUnistyles() const isWeb = Platform.OS === 'web' const [prefsOpen, setPrefsOpen] = useState(false) - const dropdownMaxWidth = isWeb ? 360 : undefined + const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null) + + const providerAnchorRef = useRef(null) + const modeAnchorRef = useRef(null) + const modelAnchorRef = useRef(null) + const thinkingAnchorRef = useRef(null) const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0) const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0) @@ -119,18 +125,47 @@ function ControlledStatusBar({ const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0 + const SEARCH_THRESHOLD = 6 + + const comboboxProviderOptions = useMemo( + () => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })), + [providerOptions] + ) + const comboboxModeOptions = useMemo( + () => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })), + [modeOptions] + ) + const comboboxModelOptions = useMemo( + () => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })), + [modelOptions] + ) + const comboboxThinkingOptions = useMemo( + () => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })), + [thinkingOptions] + ) + + const handleOpenChange = useCallback( + (selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => { + setOpenSelector(nextOpen ? selector : null) + }, + [] + ) + return ( {isWeb ? ( <> {providerOptions && providerOptions.length > 0 ? ( - - + [ + onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')} + style={({ pressed, hovered }) => [ styles.modeBadge, hovered && styles.modeBadgeHovered, - (pressed || open) && styles.modeBadgePressed, + (pressed || openSelector === 'provider') && styles.modeBadgePressed, (disabled || !canSelectProvider) && styles.disabledBadge, ]} accessibilityRole="button" @@ -139,34 +174,31 @@ function ControlledStatusBar({ > {displayProvider} - - - {providerOptions.map((provider) => ( - onSelectProvider?.(provider.id)} - > - {provider.label} - - ))} - - + + onSelectProvider?.(id)} + searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD} + open={openSelector === 'provider'} + onOpenChange={handleOpenChange('provider')} + anchorRef={providerAnchorRef} + desktopPlacement="top-start" + /> + ) : null} {modeOptions && modeOptions.length > 0 ? ( - - + [ + onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')} + style={({ pressed, hovered }) => [ styles.modeBadge, hovered && styles.modeBadgeHovered, - (pressed || open) && styles.modeBadgePressed, + (pressed || openSelector === 'mode') && styles.modeBadgePressed, (disabled || !canSelectMode) && styles.disabledBadge, ]} accessibilityRole="button" @@ -175,68 +207,60 @@ function ControlledStatusBar({ > {displayMode} - - - {modeOptions.map((mode) => ( - onSelectMode?.(mode.id)} - > - {mode.label} - - ))} - - + + onSelectMode?.(id)} + searchable={comboboxModeOptions.length > SEARCH_THRESHOLD} + open={openSelector === 'mode'} + onOpenChange={handleOpenChange('mode')} + anchorRef={modeAnchorRef} + desktopPlacement="top-start" + /> + ) : null} - - [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || open) && styles.modeBadgePressed, - modelDisabled && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent model" - testID="agent-model-selector" - > - {displayModel} - - - - {(modelOptions ?? []).map((model) => ( - onSelectModel?.(model.id)} - > - {model.label} - - ))} - - + setOpenSelector(openSelector === 'model' ? null : 'model')} + style={({ pressed, hovered }) => [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === 'model') && styles.modeBadgePressed, + modelDisabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent model" + testID="agent-model-selector" + > + {displayModel} + + + onSelectModel?.(id)} + searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} + open={openSelector === 'model'} + onOpenChange={handleOpenChange('model')} + anchorRef={modelAnchorRef} + desktopPlacement="top-start" + /> {thinkingOptions && thinkingOptions.length > 0 ? ( - - + [ + onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')} + style={({ pressed, hovered }) => [ styles.modeBadge, hovered && styles.modeBadgeHovered, - (pressed || open) && styles.modeBadgePressed, + (pressed || openSelector === 'thinking') && styles.modeBadgePressed, (disabled || !canSelectThinking) && styles.disabledBadge, ]} accessibilityRole="button" @@ -250,24 +274,18 @@ function ControlledStatusBar({ /> {displayThinking} - - - {thinkingOptions.map((thinking) => ( - onSelectThinkingOption?.(thinking.id)} - > - {thinking.label} - - ))} - - + + onSelectThinkingOption?.(id)} + searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD} + open={openSelector === 'thinking'} + onOpenChange={handleOpenChange('thinking')} + anchorRef={thinkingAnchorRef} + desktopPlacement="top-start" + /> + ) : null} ) : ( diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 4d41c2400..313928e35 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -235,22 +235,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool [onNearBottomChange] ) - const scheduleStickToBottom = useCallback((source = 'unknown') => { - const scrollContainer = scrollContainerRef.current - if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) { - return - } - if (pendingAutoScrollFrameRef.current !== null) { - return - } - pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null - if (!followOutputRef.current) { + const scheduleStickToBottom = useCallback( + (source = 'unknown') => { + const scrollContainer = scrollContainerRef.current + if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) { return } - scrollMessagesToBottom('auto', source) - }) - }, [scrollMessagesToBottom]) + if (pendingAutoScrollFrameRef.current !== null) { + return + } + pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { + pendingAutoScrollFrameRef.current = null + if (!followOutputRef.current) { + return + } + scrollMessagesToBottom('auto', source) + }) + }, + [scrollMessagesToBottom] + ) const forceStickToBottom = useCallback(() => { cancelPendingStickToBottom() scrollMessagesToBottom('auto', 'force') diff --git a/packages/desktop/src-tauri/src/runtime_manager.rs b/packages/desktop/src-tauri/src/runtime_manager.rs index e5e28ae44..e51544354 100644 --- a/packages/desktop/src-tauri/src/runtime_manager.rs +++ b/packages/desktop/src-tauri/src/runtime_manager.rs @@ -4,6 +4,8 @@ use http::Request; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; +#[cfg(windows)] +use std::hash::{DefaultHasher, Hash, Hasher}; use std::fs; use std::io; use std::path::{Path, PathBuf}; diff --git a/packages/server/package.json b/packages/server/package.json index af2091e30..2ed2d5267 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -81,6 +81,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "fast-uri": "^3.1.0", "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", @@ -89,11 +90,11 @@ "pino": "^10.2.0", "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", + "rotating-file-stream": "^3.2.9", "sherpa-onnx": "^1.12.23", "sherpa-onnx-node": "^1.12.23", "strip-ansi": "^7.1.2", "tiny-invariant": "^1.3.3", - "rotating-file-stream": "^3.2.9", "uuid": "^9.0.1", "ws": "^8.14.2", "zod": "^3.23.8", diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 45c9718a7..56beb73a2 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -25,12 +25,9 @@ function Home() { return ( {/* Hero section with background image */} -
+
-
+