Merge remote-tracking branch 'origin/main' into dev

# Conflicts:
#	nix/package.nix
This commit is contained in:
Mohamed Boudra
2026-04-02 10:56:17 +07:00
30 changed files with 531 additions and 292 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01 ## 0.1.41 - 2026-04-01
### Fixed ### Fixed
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ="; npmDepsHash = "sha256-UtE4rf5CbPCtp86swvZ0IbU+DhGxZlEfDubAiWz2RKE=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.41", "version": "0.1.43-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.41", "version": "0.1.43-rc.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -36285,16 +36285,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.41", "version": "0.1.43-rc.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.41", "@getpaseo/expo-two-way-audio": "0.1.43-rc.1",
"@getpaseo/highlight": "0.1.41", "@getpaseo/highlight": "0.1.43-rc.1",
"@getpaseo/server": "0.1.41", "@getpaseo/server": "0.1.43-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -36411,11 +36411,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.41", "version": "0.1.43-rc.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.41", "@getpaseo/relay": "0.1.43-rc.1",
"@getpaseo/server": "0.1.41", "@getpaseo/server": "0.1.43-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -36456,11 +36456,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.41", "version": "0.1.43-rc.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.41", "@getpaseo/cli": "0.1.43-rc.1",
"@getpaseo/server": "0.1.41", "@getpaseo/server": "0.1.43-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -36494,7 +36494,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.41", "version": "0.1.43-rc.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -36695,7 +36695,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.41", "version": "0.1.43-rc.1",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -36721,7 +36721,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.41", "version": "0.1.43-rc.1",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -36737,13 +36737,13 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.41", "version": "0.1.43-rc.1",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.41", "@getpaseo/highlight": "0.1.43-rc.1",
"@getpaseo/relay": "0.1.41", "@getpaseo/relay": "0.1.43-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -37145,7 +37145,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.41", "version": "0.1.43-rc.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.41", "version": "0.1.43-rc.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.41", "version": "0.1.43-rc.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.41", "@getpaseo/expo-two-way-audio": "0.1.43-rc.1",
"@getpaseo/highlight": "0.1.41", "@getpaseo/highlight": "0.1.43-rc.1",
"@getpaseo/server": "0.1.41", "@getpaseo/server": "0.1.43-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -20,7 +20,7 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar"; import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo }; export type { DraggableListProps, DraggableRenderItemInfo };
@@ -133,8 +133,11 @@ export function DraggableList<T>({
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null); const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data; const items = dragItems ?? data;
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const scrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -177,7 +180,6 @@ export function DraggableList<T>({
); );
const ids = items.map((item, index) => keyExtractor(item, index)); const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const wrapperStyle = [ const wrapperStyle = [
{ position: "relative" as const }, { position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null, scrollEnabled ? { flex: 1, minHeight: 0 } : null,
@@ -193,12 +195,10 @@ export function DraggableList<T>({
style={style} style={style}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator} showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined} onLayout={scrollbar.onLayout}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined onScroll={scrollbar.onScroll}
} scrollEventThrottle={16}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
> >
{ListHeaderComponent} {ListHeaderComponent}
{items.length === 0 && ListEmptyComponent} {items.length === 0 && ListEmptyComponent}
@@ -259,13 +259,7 @@ export function DraggableList<T>({
{ListFooterComponent} {ListFooterComponent}
</> </>
)} )}
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View> </View>
); );
} }
@@ -4,9 +4,6 @@ import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
ListRenderItemInfo, ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable, Pressable,
Text, Text,
View, View,
@@ -53,10 +50,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" }, { value: "name", label: "Name" },
@@ -152,7 +146,9 @@ export function FileExplorerPane({
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."])); const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null); const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
});
const hasInitializedRef = useRef(false); const hasInitializedRef = useRef(false);
@@ -502,24 +498,6 @@ export function FileExplorerPane({
}); });
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]); }, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
(event: LayoutChangeEvent) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) { if (!hasWorkspaceScope) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -598,27 +576,16 @@ export function FileExplorerPane({
keyExtractor={(row) => row.entry.path} keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll" testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent} contentContainerStyle={styles.entriesContent}
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined} onLayout={scrollbar.onLayout}
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined} onScroll={scrollbar.onScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined scrollEventThrottle={16}
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24} initialNumToRender={24}
maxToRenderPerBatch={40} maxToRenderPerBatch={40}
windowSize={12} windowSize={12}
/> />
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View> </View>
)} )}
</View> </View>
+17 -56
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from "react"; import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -7,17 +7,11 @@ import {
Text, Text,
View, View,
Platform, Platform,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native"; } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { import {
highlightCode, highlightCode,
darkHighlightColors, darkHighlightColors,
@@ -123,9 +117,10 @@ function FilePreviewBody({
const colorMap = isDark ? darkHighlightColors : lightHighlightColors; const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f"; const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null); const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => { const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") { if (!preview || preview.kind !== "text") {
@@ -140,24 +135,6 @@ function FilePreviewBody({
return lineNumberGutterWidth(highlightedLines.length); return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]); }, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) { if (isLoading && !preview) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -197,13 +174,11 @@ function FilePreviewBody({
<RNScrollView <RNScrollView
ref={previewScrollRef} ref={previewScrollRef}
style={styles.previewContent} style={styles.previewContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined} onLayout={scrollbar.onLayout}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined} onScroll={scrollbar.onScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined scrollEventThrottle={16}
} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
> >
{isMobile ? ( {isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View> <View style={styles.previewCodeScrollContent}>{codeLines}</View>
@@ -218,13 +193,7 @@ function FilePreviewBody({
</RNScrollView> </RNScrollView>
)} )}
</RNScrollView> </RNScrollView>
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View> </View>
); );
} }
@@ -236,13 +205,11 @@ function FilePreviewBody({
ref={previewScrollRef} ref={previewScrollRef}
style={styles.previewContent} style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent} contentContainerStyle={styles.previewImageScrollContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined} onLayout={scrollbar.onLayout}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined} onScroll={scrollbar.onScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined scrollEventThrottle={16}
} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
> >
<RNImage <RNImage
source={{ source={{
@@ -252,13 +219,7 @@ function FilePreviewBody({
resizeMode="contain" resizeMode="contain"
/> />
</RNScrollView> </RNScrollView>
<WebDesktopScrollbarOverlay {scrollbar.overlay}
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View> </View>
); );
} }
+10 -26
View File
@@ -62,10 +62,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets"; import { lineNumberGutterWidth } from "@/components/code-insets";
import { import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
@@ -429,7 +426,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({}); const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null); const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics(); const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
const diffListScrollOffsetRef = useRef(0); const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0); const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({}); const headerHeightByPathRef = useRef<Record<string, number>>({});
@@ -515,11 +514,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const handleDiffListScroll = useCallback( const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
if (showDesktopWebScrollbar) { scrollbar.onScroll(event);
diffScrollbarMetrics.onScroll(event);
}
}, },
[diffScrollbarMetrics, showDesktopWebScrollbar], [scrollbar.onScroll],
); );
const handleDiffListLayout = useCallback( const handleDiffListLayout = useCallback(
@@ -529,11 +526,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return; return;
} }
diffListViewportHeightRef.current = height; diffListViewportHeightRef.current = height;
if (showDesktopWebScrollbar) { scrollbar.onLayout(event);
diffScrollbarMetrics.onLayout(event);
}
}, },
[diffScrollbarMetrics, showDesktopWebScrollbar], [scrollbar.onLayout],
); );
const computeHeaderOffset = useCallback( const computeHeaderOffset = useCallback(
@@ -844,9 +839,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
testID="git-diff-scroll" testID="git-diff-scroll"
onLayout={handleDiffListLayout} onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll} onScroll={handleDiffListScroll}
onContentSizeChange={ onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={16} scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh} onRefresh={handleRefresh}
@@ -1085,16 +1078,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
<View style={styles.diffContainer}> <View style={styles.diffContainer}>
{bodyContent} {bodyContent}
<WebDesktopScrollbarOverlay {hasChanges ? scrollbar.overlay : null}
enabled={showDesktopWebScrollbar && hasChanges}
metrics={diffScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
diffListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View> </View>
</View> </View>
); );
+64 -37
View File
@@ -12,7 +12,15 @@ import {
Platform, Platform,
BackHandler, BackHandler,
} from "react-native"; } from "react-native";
import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react"; import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
useImperativeHandle,
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native"; import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
@@ -33,6 +41,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { import {
@@ -572,6 +581,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return null; return null;
}, []); }, []);
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (IS_WEB) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
const ref = target === "root" ? rootRef.current : inputWrapperRef.current; const ref = target === "root" ? rootRef.current : inputWrapperRef.current;
if (!ref) return null; if (!ref) return null;
@@ -913,42 +934,45 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
)} )}
{/* Text input */} {/* Text input */}
<TextInput <View style={styles.textInputScrollWrapper}>
ref={textInputRef} <TextInput
value={value} ref={textInputRef}
onChangeText={handleInputChange} value={value}
placeholder={placeholder} onChangeText={handleInputChange}
placeholderTextColor={theme.colors.surface4} placeholder={placeholder}
accessibilityLabel="Message agent..." placeholderTextColor={theme.colors.surface4}
onFocus={() => { accessibilityLabel="Message agent..."
isInputFocusedRef.current = true; onFocus={() => {
onFocusChange?.(true); isInputFocusedRef.current = true;
}} onFocusChange?.(true);
onBlur={() => { }}
isInputFocusedRef.current = false; onBlur={() => {
onFocusChange?.(false); isInputFocusedRef.current = false;
}} onFocusChange?.(false);
style={[ }}
styles.textInput, style={[
IS_WEB styles.textInput,
? { IS_WEB
height: inputHeight, ? {
minHeight: MIN_INPUT_HEIGHT, height: inputHeight,
maxHeight: MAX_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
} maxHeight: MAX_INPUT_HEIGHT,
: { }
minHeight: MIN_INPUT_HEIGHT, : {
maxHeight: MAX_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
}, maxHeight: MAX_INPUT_HEIGHT,
]} },
multiline ]}
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} multiline
onContentSizeChange={handleContentSizeChange} scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onSelectionChange={handleSelectionChange} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
autoFocus={IS_WEB && autoFocus} onSelectionChange={handleSelectionChange}
/> autoFocus={IS_WEB && autoFocus}
/>
{inputScrollbar}
</View>
{/* Button row */} {/* Button row */}
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
@@ -1189,6 +1213,9 @@ const styles = StyleSheet.create(((theme: any) => ({
removeImageButtonVisible: { removeImageButtonVisible: {
opacity: 1, opacity: 1,
}, },
textInputScrollWrapper: {
position: "relative",
},
textInput: { textInput: {
width: "100%", width: "100%",
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -23,21 +23,7 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1; const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style"; import { useWebElementScrollbar } from "./use-web-scrollbar";
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
scrollbar-width: none;
-ms-overflow-style: none;
}
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
`;
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void { function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging. // Intentionally disabled: this path is too noisy during voice debugging.
@@ -119,8 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled, scrollEnabled,
isMobileBreakpoint, isMobileBreakpoint,
} = props; } = props;
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar");
const scrollContainerRef = useRef<HTMLElement | null>(null); const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null); const contentRef = useRef<HTMLElement | null>(null);
const [followOutput, setFollowOutputr] = useState(true); const [followOutput, setFollowOutputr] = useState(true);
@@ -142,8 +126,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null); const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null); const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null); const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const showDesktopWebScrollbar = !isMobileBreakpoint; const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
contentRef,
});
const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const { const {
renderHistoryVirtualizedRow, renderHistoryVirtualizedRow,
@@ -271,33 +258,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange(true); onNearBottomChange(true);
return; return;
} }
streamScrollbarMetrics.onContentSizeChange(
scrollContainer.clientWidth,
scrollContainer.scrollHeight,
);
streamScrollbarMetrics.onLayout({
nativeEvent: {
layout: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
x: 0,
y: 0,
},
},
} as never);
streamScrollbarMetrics.onScroll({
nativeEvent: {
contentOffset: { x: 0, y: scrollContainer.scrollTop },
contentSize: {
width: scrollContainer.clientWidth,
height: scrollContainer.scrollHeight,
},
layoutMeasurement: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
},
},
} as never);
syncNearBottom(scrollContainer, onNearBottomChange); syncNearBottom(scrollContainer, onNearBottomChange);
const currentMetrics = { const currentMetrics = {
scrollTop: scrollContainer.scrollTop, scrollTop: scrollContainer.scrollTop,
@@ -323,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...currentMetrics, ...currentMetrics,
}); });
} }
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics]); }, [onNearBottomChange, props.agentId]);
const handleDomScroll = useCallback(() => { const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
@@ -711,7 +671,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return ( return (
<> <>
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
<div <div
ref={(node) => { ref={(node) => {
scrollContainerRef.current = node; scrollContainerRef.current = node;
@@ -759,20 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{shouldRenderEmpty ? listEmptyComponent : null} {shouldRenderEmpty ? listEmptyComponent : null}
</div> </div>
</div> </div>
<WebDesktopScrollbarOverlay {scrollbarOverlay}
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={false}
onScrollToOffset={(nextOffset) => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" });
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
updateScrollMetrics();
}}
/>
</> </>
); );
} }
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import {
Platform,
type FlatList,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollView,
} from "react-native";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
type ScrollbarMetrics,
} from "./web-desktop-scrollbar";
const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
function ensureHideScrollbarStyle(): void {
if (typeof document === "undefined") return;
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
const style = document.createElement("style");
style.id = HIDE_SCROLLBAR_STYLE_ID;
style.textContent =
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
document.head.appendChild(style);
}
function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
return (
Math.abs(a.offset - b.offset) > METRICS_EPSILON ||
Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON ||
Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON
);
}
// ── DOM element scrollbar ────────────────────────────────────────────
// Fully automatic: listens to scroll/input/resize events on the element,
// hides the native scrollbar, and returns a themed overlay or null.
export function useWebElementScrollbar(
elementRef: RefObject<HTMLElement | null>,
options?: {
enabled?: boolean;
contentRef?: RefObject<HTMLElement | null>;
},
): ReactNode {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
useEffect(() => {
if (!enabled) return;
const element = elementRef.current;
if (!element) return;
element.setAttribute("data-hide-scrollbar", "");
(element.style as any).scrollbarWidth = "none";
(element.style as any).msOverflowStyle = "none";
ensureHideScrollbarStyle();
function update() {
const el = elementRef.current;
if (!el) return;
const next: ScrollbarMetrics = {
offset: el.scrollTop,
viewportSize: el.clientHeight,
contentSize: el.scrollHeight,
};
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
}
element.addEventListener("scroll", update, { passive: true });
element.addEventListener("input", update, { passive: true });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
const contentElement = contentRef?.current;
if (contentElement) {
resizeObserver.observe(contentElement);
}
update();
return () => {
element.removeEventListener("scroll", update);
element.removeEventListener("input", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
(element.style as any).scrollbarWidth = "";
(element.style as any).msOverflowStyle = "";
};
}, [contentRef, elementRef, enabled]);
const onScrollToOffset = useCallback(
(offset: number) => {
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
},
[elementRef],
);
if (!enabled) return null;
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
}
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
// Returns event handlers to wire onto your ScrollView/FlatList plus
// a renderable overlay. The overlay is null when disabled.
interface WebScrollViewScrollbar {
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onLayout: (event: LayoutChangeEvent) => void;
onContentSizeChange: (width: number, height: number) => void;
overlay: ReactNode;
}
export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean },
): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback(
(offset: number) => {
const scrollable = scrollableRef.current;
if (!scrollable) return;
if ("scrollToOffset" in scrollable) {
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
} else {
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
}
},
[scrollableRef],
);
const overlay: ReactNode = enabled ? (
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
) : null;
return {
onScroll: metricsHook.onScroll,
onLayout: metricsHook.onLayout,
onContentSizeChange: metricsHook.onContentSizeChange,
overlay,
};
}
@@ -37,7 +37,7 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
type ScrollbarMetrics = { export type ScrollbarMetrics = {
offset: number; offset: number;
viewportSize: number; viewportSize: number;
contentSize: number; contentSize: number;
+1 -1
View File
@@ -193,7 +193,7 @@ const darkSemanticColors = {
foregroundMuted: "#A1A5A4", foregroundMuted: "#A1A5A4",
// Controls // Controls
scrollbarHandle: "#71717a", // zinc-500 scrollbarHandle: "#717574", // zinc-500 w/ teal tint
// Borders // Borders
border: "#252B2A", border: "#252B2A",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.41", "version": "0.1.43-rc.1",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.41", "@getpaseo/relay": "0.1.43-rc.1",
"@getpaseo/server": "0.1.41", "@getpaseo/server": "0.1.43-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+7 -2
View File
@@ -1,7 +1,12 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server"; import {
getOrCreateServerId,
findExecutable,
quoteWindowsCommand,
applyProviderEnv,
} from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js"; import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -170,7 +175,7 @@ function checkProviderBinary(binary: string): { path: string | null; version: st
} }
const env = applyProviderEnv(process.env); const env = applyProviderEnv(process.env);
try { try {
const output = execFileSync(binaryPath, ["--version"], { const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
encoding: "utf8", encoding: "utf8",
timeout: 5000, timeout: 5000,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.41", "version": "0.1.43-rc.1",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js", "main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.41", "@getpaseo/cli": "0.1.43-rc.1",
"@getpaseo/server": "0.1.41", "@getpaseo/server": "0.1.43-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.41", "version": "0.1.43-rc.1",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"main": "build/index.js", "main": "build/index.js",
"types": "build/index.d.ts", "types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.41", "version": "0.1.43-rc.1",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.41", "version": "0.1.43-rc.1",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.41", "version": "0.1.43-rc.1",
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.41", "@getpaseo/highlight": "0.1.43-rc.1",
"@getpaseo/relay": "0.1.41", "@getpaseo/relay": "0.1.43-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { import {
findExecutable, findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
applyProviderEnv, applyProviderEnv,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
@@ -218,3 +220,75 @@ describe("findExecutable", () => {
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex"); expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
}); });
}); });
describe("quoteWindowsCommand", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows path with spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("returns the command unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("returns the command unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
describe("quoteWindowsArgument", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows argument with spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("returns the argument unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
@@ -281,6 +281,31 @@ export function findExecutable(
} }
} }
/**
* When spawning with `shell: true` on Windows, the command is passed to
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
*/
export function quoteWindowsCommand(command: string): string {
if (process.platform !== "win32") return command;
if (!command.includes(" ")) return command;
if (command.startsWith('"') && command.endsWith('"')) return command;
return `"${command}"`;
}
/**
* `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`.
* Any argument containing spaces must be quoted or it will be split before the
* child process sees it.
*/
export function quoteWindowsArgument(argument: string): string {
if (process.platform !== "win32") return argument;
if (!argument.includes(" ")) return argument;
if (argument.startsWith('"') && argument.endsWith('"')) return argument;
return `"${argument}"`;
}
export function isCommandAvailable(command: string): boolean { export function isCommandAvailable(command: string): boolean {
return findExecutable(command) !== null; return findExecutable(command) !== null;
} }
@@ -213,6 +213,25 @@ describe("ClaudeAgentSession integration", () => {
} }
}, 60_000); }, 60_000);
test.runIf(canRunClaudeIntegration)(
"keeps bypassPermissions available after a thinking-option restart",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-bypass-restart-",
modeId: "bypassPermissions",
});
try {
await handle.session.setMode("acceptEdits");
await handle.session.setThinkingOption("high");
await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined();
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)( test.runIf(canRunClaudeIntegration)(
"supportedModels returns the current abstract Claude SDK model shape", "supportedModels returns the current abstract Claude SDK model shape",
async () => { async () => {
@@ -783,6 +783,58 @@ describe("ClaudeAgentSession redesign invariants", () => {
} }
}); });
test("preserves bypass capability across query restarts triggered by thinking changes", async () => {
const capturedOptions: Array<{
permissionMode?: string;
allowDangerouslySkipPermissions?: boolean;
effort?: string;
}> = [];
sdkQueryFactory.mockImplementation(
({
options,
}: {
options: {
permissionMode?: string;
allowDangerouslySkipPermissions?: boolean;
effort?: string;
};
}) => {
capturedOptions.push({
permissionMode: options.permissionMode,
allowDangerouslySkipPermissions: options.allowDangerouslySkipPermissions,
effort: options.effort,
});
return createBaseQueryMock(
vi.fn(async () => ({ done: true, value: undefined })),
);
},
);
const session = await createSession();
try {
await session.setMode("bypassPermissions");
await session.setMode("acceptEdits");
await session.setThinkingOption("high");
await session.setMode("bypassPermissions");
expect(capturedOptions).toHaveLength(2);
expect(capturedOptions[0]).toMatchObject({
permissionMode: "default",
allowDangerouslySkipPermissions: true,
});
expect(capturedOptions[1]).toMatchObject({
permissionMode: "acceptEdits",
allowDangerouslySkipPermissions: true,
effort: "high",
});
} finally {
await session.close();
}
});
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => { test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession(); const session = await createSession();
const internal = session as unknown as { const internal = session as unknown as {
@@ -70,6 +70,8 @@ import type {
import { import {
applyProviderEnv, applyProviderEnv,
findExecutable, findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -213,7 +215,10 @@ function applyRuntimeSettingsToClaudeOptions(
const isDefaultRuntime = const isDefaultRuntime =
resolved.command === "node" || resolved.command === "bun"; resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command; const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawn(command, resolved.args, { const child = spawn(
quoteWindowsCommand(command),
resolved.args.map((argument) => quoteWindowsArgument(argument)),
{
cwd: spawnOptions.cwd, cwd: spawnOptions.cwd,
env: { env: {
...applyProviderEnv(spawnOptions.env, runtimeSettings), ...applyProviderEnv(spawnOptions.env, runtimeSettings),
@@ -222,7 +227,8 @@ function applyRuntimeSettingsToClaudeOptions(
shell: process.platform === "win32", shell: process.platform === "win32",
signal: spawnOptions.signal, signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); },
);
if (typeof options.stderr === "function") { if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => { child.stderr?.on("data", (chunk: Buffer | string) => {
options.stderr?.(chunk.toString()); options.stderr?.(chunk.toString());
@@ -1886,6 +1892,10 @@ class ClaudeAgentSession implements AgentSession {
cwd: this.config.cwd, cwd: this.config.cwd,
includePartialMessages: true, includePartialMessages: true,
permissionMode: this.currentMode, permissionMode: this.currentMode,
// Dynamic mode switching can recreate the underlying Claude query. Keep the
// bypass launch capability available so later setPermissionMode("bypassPermissions")
// calls do not fail after a model/thinking/rewind-driven restart.
allowDangerouslySkipPermissions: true,
agents: this.defaults?.agents, agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest, canUseTool: this.handlePermissionRequest,
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
@@ -42,6 +42,8 @@ import {
import { import {
applyProviderEnv, applyProviderEnv,
findExecutable, findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
@@ -3419,12 +3421,16 @@ export class CodexAppServerAgentClient implements AgentClient {
}, },
"Spawning Codex app server", "Spawning Codex app server",
); );
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], { return spawn(
detached: process.platform !== "win32", quoteWindowsCommand(launchPrefix.command),
shell: process.platform === "win32", [...launchPrefix.args, "app-server"].map((argument) => quoteWindowsArgument(argument)),
stdio: ["pipe", "pipe", "pipe"], {
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), detached: process.platform !== "win32",
}); shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
},
);
} }
async createSession( async createSession(
@@ -32,6 +32,8 @@ import type {
import { import {
applyProviderEnv, applyProviderEnv,
findExecutable, findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
@@ -333,8 +335,10 @@ export class OpenCodeServerManager {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.server = spawn( this.server = spawn(
launchPrefix.command, quoteWindowsCommand(launchPrefix.command),
[...launchPrefix.args, "serve", "--port", String(this.port)], [...launchPrefix.args, "serve", "--port", String(this.port)].map((argument) =>
quoteWindowsArgument(argument),
),
{ {
shell: process.platform === "win32", shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
+6 -1
View File
@@ -30,7 +30,12 @@ export {
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; } from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Provider binary resolution // Provider binary resolution
export { findExecutable, applyProviderEnv } from "./agent/provider-launch-config.js"; export {
findExecutable,
applyProviderEnv,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./agent/provider-launch-config.js";
// Agent SDK types for CLI commands // Agent SDK types for CLI commands
export type { export type {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.41", "version": "0.1.43-rc.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {