Merge remote-tracking branch 'origin/main' into pr60-sync
# Conflicts: # packages/app/src/components/message-input.tsx
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
|
||||
@@ -394,7 +394,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
||||
await page.getByTestId('changes-menu-archive-worktree').click();
|
||||
// Archiving a worktree deletes agents and redirects to home
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 });
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
// Repo inspection is async; wait until git options are interactive again.
|
||||
|
||||
@@ -20,6 +20,22 @@ const webEcosystemStyles = /* css */ `
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-testid="sidebar-agent-list-scroll"],
|
||||
[data-testid="agent-chat-scroll"],
|
||||
[data-testid="git-diff-scroll"],
|
||||
[data-testid="file-explorer-tree-scroll"] {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
[data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="agent-chat-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="git-diff-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
function WebRespectfulStyleReset() {
|
||||
|
||||
@@ -42,12 +42,14 @@ import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -57,6 +59,15 @@ function PushNotificationRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (getTauri()) {
|
||||
void ensureOsNotificationPermission().then((granted) => {
|
||||
console.log(
|
||||
"[OSNotifications][Tauri] Startup permission preflight result:",
|
||||
granted ? "granted" : "not-granted"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
const openFromWebClick = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,10 @@ import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
@@ -95,12 +99,16 @@ export function AgentStreamView({
|
||||
}: AgentStreamViewProps) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const hasScrolledInitially = useRef(false);
|
||||
const hasAutoScrolledOnce = useRef(false);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
@@ -184,8 +192,12 @@ export function AgentStreamView({
|
||||
isNearBottomRef.current = nearBottom;
|
||||
setIsNearBottom(nearBottom);
|
||||
}
|
||||
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[insets.bottom]
|
||||
[insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||
);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
@@ -647,14 +659,25 @@ export function AgentStreamView({
|
||||
data={flatListData}
|
||||
renderItem={renderStreamItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
ListHeaderComponentStyle={headerGapStyle}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: 0,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
style={stylesheet.list}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar
|
||||
? streamScrollbarMetrics.onLayout
|
||||
: undefined
|
||||
}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? streamScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={flatListExtraData}
|
||||
@@ -667,9 +690,22 @@ export function AgentStreamView({
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
inverted
|
||||
/>
|
||||
</MessageOuterSpacingProvider>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={streamScrollbarMetrics}
|
||||
inverted
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
streamScrollbarMetrics.setOffset(nextOffset);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!isNearBottom && (
|
||||
|
||||
@@ -18,10 +18,12 @@ export function DraggableList<T>({
|
||||
onDragEnd,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
simultaneousGestureRef,
|
||||
@@ -69,6 +71,7 @@ export function DraggableList<T>({
|
||||
|
||||
return (
|
||||
<DraggableFlatList
|
||||
testID={testID}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={handleRenderItem}
|
||||
|
||||
@@ -16,10 +16,12 @@ export interface DraggableListProps<T> {
|
||||
onDragEnd: (data: T[]) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
ListFooterComponent?: ReactElement | null;
|
||||
ListHeaderComponent?: ReactElement | null;
|
||||
ListEmptyComponent?: ReactElement | null;
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
enableDesktopWebScrollbar?: boolean;
|
||||
refreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
/** Fill remaining space when content is smaller than container */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState, useRef, type ReactElement } from "react";
|
||||
import { View, ScrollView } from "react-native";
|
||||
import { useCallback, useRef, useState, type ReactElement } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -23,6 +23,10 @@ import type {
|
||||
DraggableListProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
@@ -99,14 +103,18 @@ export function DraggableList<T>({
|
||||
onDragEnd,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar = false,
|
||||
// simultaneousGestureRef is native-only, ignored on web
|
||||
}: DraggableListProps<T>) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState(data);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
// Sync items with data prop
|
||||
if (data !== items && !activeId) {
|
||||
@@ -151,39 +159,60 @@ export function DraggableList<T>({
|
||||
);
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
<View style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={
|
||||
showCustomScrollbar ? false : showsVerticalScrollIndicator
|
||||
}
|
||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||
onContentSizeChange={
|
||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
||||
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</ScrollView>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</ScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showCustomScrollbar}
|
||||
metrics={scrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
scrollbarMetrics.setOffset(nextOffset);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
FlatList,
|
||||
Image as RNImage,
|
||||
ListRenderItemInfo,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
@@ -63,6 +66,10 @@ import {
|
||||
type SortOption,
|
||||
} from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
@@ -86,6 +93,7 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const daemonProfile = connectionStates.get(serverId)?.daemon;
|
||||
@@ -136,6 +144,8 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const wasInlinePreviewVisibleRef = useRef(false);
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
// Bottom sheet for file preview (mobile)
|
||||
const previewSheetRef = useRef<BottomSheetModal>(null);
|
||||
@@ -592,6 +602,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
@@ -704,15 +732,41 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar ? handleTreeListLayout : undefined
|
||||
}
|
||||
onScroll={
|
||||
showDesktopWebScrollbar ? handleTreeListScroll : undefined
|
||||
}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? treeScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
treeScrollbarMetrics.setOffset(nextOffset);
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
@@ -741,15 +795,41 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar ? handleTreeListLayout : undefined
|
||||
}
|
||||
onScroll={
|
||||
showDesktopWebScrollbar ? handleTreeListScroll : undefined
|
||||
}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? treeScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
treeScrollbarMetrics.setOffset(nextOffset);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
Archive,
|
||||
@@ -50,7 +50,11 @@ import {
|
||||
type ActionStatus,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
// =============================================================================
|
||||
@@ -469,9 +473,13 @@ type DiffFlatItem =
|
||||
|
||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, cwd });
|
||||
@@ -517,6 +525,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const diffListScrollOffsetRef = useRef(0);
|
||||
const diffListViewportHeightRef = useRef(0);
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
@@ -623,17 +632,29 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
bodyHeightByPathRef.current[path] = height;
|
||||
}, []);
|
||||
|
||||
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
const handleDiffListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
);
|
||||
|
||||
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
}, []);
|
||||
const handleDiffListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
(path: string): number => {
|
||||
@@ -796,10 +817,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
setActionError(null);
|
||||
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
void runMergeBranch({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
@@ -821,15 +846,16 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
@@ -887,12 +913,27 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
? undefined
|
||||
: `${branchLabel} -> ${baseRefLabel}`;
|
||||
}, [baseRefLabel, branchLabel]);
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const isMergedPullRequest = Boolean(prStatus?.isMerged);
|
||||
const currentBranch = gitStatus?.currentBranch;
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
const shouldPromoteArchive =
|
||||
isPaseoOwnedWorktree &&
|
||||
!hasUncommittedChanges &&
|
||||
(postShipArchiveSuggested || isMergedPullRequest);
|
||||
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
const archiveDisabled =
|
||||
@@ -956,7 +997,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? diffScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||
@@ -969,11 +1016,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const currentBranch = gitStatus?.currentBranch;
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
useEffect(() => {
|
||||
setPostShipArchiveSuggested(false);
|
||||
}, [cwd]);
|
||||
|
||||
// ==========================================================================
|
||||
// Git Actions (Data-Oriented)
|
||||
@@ -1063,16 +1108,21 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
});
|
||||
}
|
||||
|
||||
// Update from base - only when not on base branch
|
||||
if (!isOnBaseBranch) {
|
||||
// Update/sync from base
|
||||
if (!isOnBaseBranch || hasRemote) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: `Update from ${baseRefLabel}`,
|
||||
label: isOnBaseBranch ? `Sync ${baseRefLabel} from origin` : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
@@ -1095,8 +1145,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
// Rule 0: Post-ship in worktree -> Archive
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
}
|
||||
// Rule 1: Uncommitted changes → Commit
|
||||
if (hasUncommittedChanges) {
|
||||
else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
}
|
||||
// Rule 2: Ahead of origin → Push
|
||||
@@ -1107,7 +1161,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
}
|
||||
// Rule 4: Ahead of base → Ship action based on preference
|
||||
// Rule 4: On base branch -> surface sync explicitly
|
||||
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
}
|
||||
// Rule 5: Ahead of base → Ship action based on preference
|
||||
else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
@@ -1127,20 +1185,25 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
// Secondary actions: ship-related + merge from base + push (excluding primary)
|
||||
const secondaryIds: GitActionId[] = ["merge-branch", "create-pr", "view-pr", "merge-from-base", "push"];
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
// Menu actions: archive worktree only
|
||||
const menu = allActions.has("archive-worktree")
|
||||
? [allActions.get("archive-worktree")!]
|
||||
: [];
|
||||
// Menu actions: none for now (all actionable items are in primary/secondary)
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||
@@ -1331,7 +1394,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
<View style={styles.diffContainer}>
|
||||
{bodyContent}
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar && hasChanges}
|
||||
metrics={diffScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
diffListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
diffScrollbarMetrics.setOffset(nextOffset);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1531,6 +1607,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,11 @@ import {
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
StyleSheet,
|
||||
UnistylesRuntime,
|
||||
useUnistyles,
|
||||
} from "react-native-unistyles";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { Archive, Check, ChevronDown } from "lucide-react-native";
|
||||
import {
|
||||
@@ -434,6 +438,9 @@ export function SidebarAgentList({
|
||||
}: SidebarAgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
|
||||
const projectFilterAnchorRef = useRef<View>(null);
|
||||
|
||||
@@ -820,10 +827,12 @@ export function SidebarAgentList({
|
||||
styles.listContent,
|
||||
isSelectionMode ? styles.listContentSelectionMode : null,
|
||||
]}
|
||||
testID="sidebar-agent-list-scroll"
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderRow}
|
||||
onDragEnd={() => {}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
enableDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
ListFooterComponent={listFooterComponent}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Theme } from "@/styles/theme";
|
||||
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { File, Folder } from 'lucide-react-native'
|
||||
import { Theme } from '@/styles/theme'
|
||||
import { getAutocompleteScrollOffset } from './autocomplete-utils'
|
||||
|
||||
export interface AutocompleteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
description?: string;
|
||||
id: string
|
||||
label: string
|
||||
detail?: string
|
||||
description?: string
|
||||
kind?: 'command' | 'file' | 'directory'
|
||||
}
|
||||
|
||||
interface AutocompleteProps {
|
||||
options: readonly AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText?: string;
|
||||
emptyText?: string;
|
||||
maxHeight?: number;
|
||||
options: readonly AutocompleteOption[]
|
||||
selectedIndex: number
|
||||
onSelect: (option: AutocompleteOption) => void
|
||||
isLoading?: boolean
|
||||
errorMessage?: string
|
||||
loadingText?: string
|
||||
emptyText?: string
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
@@ -28,23 +30,24 @@ export function Autocomplete({
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
errorMessage,
|
||||
loadingText = "Loading...",
|
||||
emptyText = "No results found",
|
||||
loadingText = 'Loading...',
|
||||
emptyText = 'No results found',
|
||||
maxHeight = 220,
|
||||
}: AutocompleteProps) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
const scrollOffsetRef = useRef(0);
|
||||
const { theme } = useUnistyles()
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map())
|
||||
const viewportHeightRef = useRef(0)
|
||||
const scrollOffsetRef = useRef(0)
|
||||
|
||||
const ensureActiveItemVisible = useCallback(() => {
|
||||
if (selectedIndex < 0) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const layout = rowLayoutsRef.current.get(selectedIndex);
|
||||
const layout = rowLayoutsRef.current.get(selectedIndex)
|
||||
if (!layout) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const nextOffset = getAutocompleteScrollOffset({
|
||||
@@ -52,60 +55,60 @@ export function Autocomplete({
|
||||
viewportHeight: viewportHeightRef.current,
|
||||
itemTop: layout.top,
|
||||
itemHeight: layout.height,
|
||||
});
|
||||
})
|
||||
|
||||
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
scrollOffsetRef.current = nextOffset;
|
||||
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}, [selectedIndex]);
|
||||
scrollOffsetRef.current = nextOffset
|
||||
scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
|
||||
}, [selectedIndex])
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
scrollRef.current?.scrollToEnd({ animated: false })
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
});
|
||||
}, []);
|
||||
scrollRef.current?.scrollToEnd({ animated: false })
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
rowLayoutsRef.current.clear();
|
||||
scrollOffsetRef.current = 0;
|
||||
}, [options]);
|
||||
rowLayoutsRef.current.clear()
|
||||
scrollOffsetRef.current = 0
|
||||
}, [options])
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
pinToBottom();
|
||||
}, [options, pinToBottom]);
|
||||
pinToBottom()
|
||||
}, [options, pinToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(ensureActiveItemVisible);
|
||||
const raf = requestAnimationFrame(ensureActiveItemVisible)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [ensureActiveItemVisible, options.length]);
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [ensureActiveItemVisible, options.length])
|
||||
|
||||
const handleScrollViewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
viewportHeightRef.current = event.nativeEvent.layout.height;
|
||||
ensureActiveItemVisible();
|
||||
viewportHeightRef.current = event.nativeEvent.layout.height
|
||||
ensureActiveItemVisible()
|
||||
},
|
||||
[ensureActiveItemVisible]
|
||||
);
|
||||
)
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(index: number, event: LayoutChangeEvent) => {
|
||||
rowLayoutsRef.current.set(index, {
|
||||
top: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
ensureActiveItemVisible();
|
||||
})
|
||||
ensureActiveItemVisible()
|
||||
},
|
||||
[ensureActiveItemVisible]
|
||||
);
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -114,7 +117,7 @@ export function Autocomplete({
|
||||
<Text style={styles.emptyText}>{loadingText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
@@ -124,7 +127,7 @@ export function Autocomplete({
|
||||
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
@@ -134,7 +137,7 @@ export function Autocomplete({
|
||||
<Text style={styles.emptyText}>{emptyText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -144,7 +147,7 @@ export function Autocomplete({
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={(event) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
@@ -152,7 +155,7 @@ export function Autocomplete({
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isSelected = index === selectedIndex
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
@@ -163,12 +166,19 @@ export function Autocomplete({
|
||||
(hovered || pressed || isSelected) && styles.itemActive,
|
||||
]}
|
||||
>
|
||||
{option.kind === 'directory' || option.kind === 'file' ? (
|
||||
<View style={styles.itemLeading}>
|
||||
{option.kind === 'directory' ? (
|
||||
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.itemMain}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemLabel}>{option.label}</Text>
|
||||
{option.detail ? (
|
||||
<Text style={styles.itemDetail}>{option.detail}</Text>
|
||||
) : null}
|
||||
{option.detail ? <Text style={styles.itemDetail}>{option.detail}</Text> : null}
|
||||
</View>
|
||||
{option.description ? (
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
@@ -177,11 +187,11 @@ export function Autocomplete({
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
@@ -190,7 +200,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
@@ -200,12 +210,18 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 36,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
itemLeading: {
|
||||
width: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
itemActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
@@ -214,8 +230,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
itemLabel: {
|
||||
@@ -240,4 +256,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
})) as any) as Record<string, any>;
|
||||
})) as any) as Record<string, any>
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
export type ComboboxOptionKind = 'directory' | 'file'
|
||||
|
||||
export interface ComboboxOptionModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
kind?: "directory";
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
kind?: ComboboxOptionKind
|
||||
}
|
||||
|
||||
export interface BuildVisibleComboboxOptionsInput {
|
||||
options: ComboboxOptionModel[];
|
||||
searchQuery: string;
|
||||
searchable: boolean;
|
||||
allowCustomValue: boolean;
|
||||
customValuePrefix: string;
|
||||
customValueDescription?: string;
|
||||
customValueKind?: "directory";
|
||||
options: ComboboxOptionModel[]
|
||||
searchQuery: string
|
||||
searchable: boolean
|
||||
allowCustomValue: boolean
|
||||
customValuePrefix: string
|
||||
customValueDescription?: string
|
||||
customValueKind?: ComboboxOptionKind
|
||||
}
|
||||
|
||||
export function shouldShowCustomComboboxOption(input: {
|
||||
options: ComboboxOptionModel[];
|
||||
searchQuery: string;
|
||||
searchable: boolean;
|
||||
allowCustomValue: boolean;
|
||||
options: ComboboxOptionModel[]
|
||||
searchQuery: string
|
||||
searchable: boolean
|
||||
allowCustomValue: boolean
|
||||
}): boolean {
|
||||
const sanitizedSearchValue = input.searchQuery.trim();
|
||||
const sanitizedSearchValue = input.searchQuery.trim()
|
||||
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
return !input.options.some(
|
||||
(opt) =>
|
||||
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
||||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function buildVisibleComboboxOptions(
|
||||
input: BuildVisibleComboboxOptionsInput
|
||||
): ComboboxOptionModel[] {
|
||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
|
||||
const filteredOptions = normalizedSearch
|
||||
? input.options.filter(
|
||||
(opt) =>
|
||||
@@ -44,52 +46,50 @@ export function buildVisibleComboboxOptions(
|
||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.description?.toLowerCase().includes(normalizedSearch)
|
||||
)
|
||||
: input.options;
|
||||
: input.options
|
||||
|
||||
const sanitizedSearchValue = input.searchQuery.trim();
|
||||
const sanitizedSearchValue = input.searchQuery.trim()
|
||||
const showCustomOption = shouldShowCustomComboboxOption({
|
||||
options: input.options,
|
||||
searchQuery: input.searchQuery,
|
||||
searchable: input.searchable,
|
||||
allowCustomValue: input.allowCustomValue,
|
||||
});
|
||||
})
|
||||
|
||||
const visibleOptions: ComboboxOptionModel[] = [];
|
||||
const visibleOptions: ComboboxOptionModel[] = []
|
||||
|
||||
if (showCustomOption) {
|
||||
const trimmedPrefix = input.customValuePrefix.trim();
|
||||
const trimmedPrefix = input.customValuePrefix.trim()
|
||||
const customLabel =
|
||||
trimmedPrefix.length > 0
|
||||
? `${trimmedPrefix} "${sanitizedSearchValue}"`
|
||||
: sanitizedSearchValue;
|
||||
trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
|
||||
visibleOptions.push({
|
||||
id: sanitizedSearchValue,
|
||||
label: customLabel,
|
||||
description: input.customValueDescription,
|
||||
kind: input.customValueKind,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
visibleOptions.push(...filteredOptions);
|
||||
return visibleOptions;
|
||||
visibleOptions.push(...filteredOptions)
|
||||
return visibleOptions
|
||||
}
|
||||
|
||||
export function orderVisibleComboboxOptions(
|
||||
visibleOptions: ComboboxOptionModel[],
|
||||
optionsPosition: "below-search" | "above-search"
|
||||
optionsPosition: 'below-search' | 'above-search'
|
||||
): ComboboxOptionModel[] {
|
||||
if (optionsPosition !== "above-search") {
|
||||
return visibleOptions;
|
||||
if (optionsPosition !== 'above-search') {
|
||||
return visibleOptions
|
||||
}
|
||||
return [...visibleOptions].reverse();
|
||||
return [...visibleOptions].reverse()
|
||||
}
|
||||
|
||||
export function getComboboxFallbackIndex(
|
||||
itemCount: number,
|
||||
optionsPosition: "below-search" | "above-search"
|
||||
optionsPosition: 'below-search' | 'above-search'
|
||||
): number {
|
||||
if (itemCount <= 0) {
|
||||
return -1;
|
||||
return -1
|
||||
}
|
||||
return optionsPosition === "above-search" ? itemCount - 1 : 0;
|
||||
return optionsPosition === 'above-search' ? itemCount - 1 : 0
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -10,87 +10,88 @@ import {
|
||||
Platform,
|
||||
StatusBar,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
} from 'react-native'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, Folder, Search } from "lucide-react-native";
|
||||
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
|
||||
import { getNextActiveIndex } from "./combobox-keyboard";
|
||||
} from '@gorhom/bottom-sheet'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { Check, File, Folder, Search } from 'lucide-react-native'
|
||||
import {
|
||||
flip,
|
||||
offset as floatingOffset,
|
||||
shift,
|
||||
size as floatingSize,
|
||||
useFloating,
|
||||
} from '@floating-ui/react-native'
|
||||
import { getNextActiveIndex } from './combobox-keyboard'
|
||||
import {
|
||||
buildVisibleComboboxOptions,
|
||||
getComboboxFallbackIndex,
|
||||
orderVisibleComboboxOptions,
|
||||
shouldShowCustomComboboxOption,
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
} from './combobox-options'
|
||||
import type { ComboboxOptionModel } from './combobox-options'
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = Platform.OS === 'web'
|
||||
|
||||
export type ComboboxOption = ComboboxOptionModel;
|
||||
export type ComboboxOption = ComboboxOptionModel
|
||||
|
||||
export interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onSelect: (id: string) => void;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
searchable?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
allowCustomValue?: boolean;
|
||||
customValuePrefix?: string;
|
||||
customValueDescription?: string;
|
||||
customValueKind?: "directory";
|
||||
optionsPosition?: "below-search" | "above-search";
|
||||
title?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
options: ComboboxOption[]
|
||||
value: string
|
||||
onSelect: (id: string) => void
|
||||
onSearchQueryChange?: (query: string) => void
|
||||
searchable?: boolean
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
allowCustomValue?: boolean
|
||||
customValuePrefix?: string
|
||||
customValueDescription?: string
|
||||
customValueKind?: 'directory' | 'file'
|
||||
optionsPosition?: 'below-search' | 'above-search'
|
||||
title?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
desktopPlacement?: 'top-start' | 'bottom-start'
|
||||
/**
|
||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||
*/
|
||||
desktopPreventInitialFlash?: boolean;
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
children?: ReactNode;
|
||||
desktopPreventInitialFlash?: boolean
|
||||
anchorRef: React.RefObject<View | null>
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function toNumericStyleValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[style, styles.bottomSheetBackground]}
|
||||
/>
|
||||
);
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
autoFocus?: boolean;
|
||||
placeholder: string
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onSubmitEditing?: () => void
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
@@ -100,18 +101,18 @@ function SearchInput({
|
||||
onSubmitEditing,
|
||||
autoFocus = false,
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||
const { theme } = useUnistyles()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
inputRef.current?.focus()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [autoFocus]);
|
||||
}, [autoFocus])
|
||||
|
||||
return (
|
||||
<View style={styles.searchInputContainer}>
|
||||
@@ -119,7 +120,7 @@ function SearchInput({
|
||||
<InputComponent
|
||||
ref={inputRef as any}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
@@ -129,17 +130,17 @@ function SearchInput({
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export interface ComboboxItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
kind?: "directory";
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
label: string
|
||||
description?: string
|
||||
kind?: 'directory' | 'file'
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function ComboboxItem({
|
||||
@@ -151,7 +152,7 @@ export function ComboboxItem({
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { theme } = useUnistyles()
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
@@ -163,15 +164,23 @@ export function ComboboxItem({
|
||||
active && styles.comboboxItemActive,
|
||||
]}
|
||||
>
|
||||
{kind === "directory" ? (
|
||||
{kind === 'directory' || kind === 'file' ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
{kind === 'directory' ? (
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<File size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? (
|
||||
@@ -180,11 +189,15 @@ export function ComboboxItem({
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
||||
return <Text testID="combobox-empty-text" style={styles.emptyText}>{children}</Text>;
|
||||
return (
|
||||
<Text testID="combobox-empty-text" style={styles.emptyText}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
@@ -193,202 +206,196 @@ export function Combobox({
|
||||
onSelect,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
placeholder = "Search...",
|
||||
placeholder = 'Search...',
|
||||
searchPlaceholder,
|
||||
emptyText = "No options match your search.",
|
||||
emptyText = 'No options match your search.',
|
||||
allowCustomValue = false,
|
||||
customValuePrefix = "Use",
|
||||
customValuePrefix = 'Use',
|
||||
customValueDescription,
|
||||
customValueKind,
|
||||
optionsPosition = "below-search",
|
||||
title = "Select",
|
||||
optionsPosition = 'below-search',
|
||||
title = 'Select',
|
||||
open,
|
||||
onOpenChange,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPlacement = 'top-start',
|
||||
desktopPreventInitialFlash = true,
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const effectiveOptionsPosition =
|
||||
isMobile ? "below-search" : optionsPosition;
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
|
||||
const isDesktopAboveSearch =
|
||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null)
|
||||
const snapPoints = useMemo(() => ['60%', '90%'], [])
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
||||
null
|
||||
)
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null)
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1)
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null)
|
||||
|
||||
const isControlled = typeof open === "boolean";
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
const isControlled = typeof open === 'boolean'
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const isOpen = isControlled ? open : internalOpen
|
||||
|
||||
const setOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
setInternalOpen(nextOpen)
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
onOpenChange?.(nextOpen)
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
)
|
||||
|
||||
const setSearchQueryWithCallback = useCallback(
|
||||
(nextQuery: string) => {
|
||||
setSearchQuery(nextQuery);
|
||||
onSearchQueryChange?.(nextQuery);
|
||||
setSearchQuery(nextQuery)
|
||||
onSearchQueryChange?.(nextQuery)
|
||||
},
|
||||
[onSearchQueryChange]
|
||||
);
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSearchQueryWithCallback("");
|
||||
}, [setOpen, setSearchQueryWithCallback]);
|
||||
setOpen(false)
|
||||
setSearchQueryWithCallback('')
|
||||
}, [setOpen, setSearchQueryWithCallback])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQueryWithCallback("");
|
||||
setSearchQueryWithCallback('')
|
||||
}
|
||||
}, [isOpen, setSearchQueryWithCallback]);
|
||||
}, [isOpen, setSearchQueryWithCallback])
|
||||
|
||||
const collisionPadding = useMemo(() => {
|
||||
const basePadding = 16;
|
||||
if (Platform.OS !== "android") return basePadding;
|
||||
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
||||
return Math.max(basePadding, statusBarHeight + basePadding);
|
||||
}, []);
|
||||
const basePadding = 16
|
||||
if (Platform.OS !== 'android') return basePadding
|
||||
const statusBarHeight = StatusBar.currentHeight ?? 0
|
||||
return Math.max(basePadding, statusBarHeight + basePadding)
|
||||
}, [])
|
||||
|
||||
const middleware = useMemo(
|
||||
() => [
|
||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
||||
floatingOffset(Platform.OS === 'web' ? 0 : 4),
|
||||
...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
|
||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||
floatingSize({
|
||||
padding: collisionPadding,
|
||||
apply({ availableWidth, availableHeight, rects }) {
|
||||
setAvailableSize((prev) => {
|
||||
const next = { width: availableWidth, height: availableHeight };
|
||||
if (!prev) return next;
|
||||
if (prev.width === next.width && prev.height === next.height) return prev;
|
||||
return next;
|
||||
});
|
||||
const next = { width: availableWidth, height: availableHeight }
|
||||
if (!prev) return next
|
||||
if (prev.width === next.width && prev.height === next.height) return prev
|
||||
return next
|
||||
})
|
||||
setReferenceWidth((prev) => {
|
||||
const next = rects.reference.width;
|
||||
if (prev === next) return prev;
|
||||
return next;
|
||||
});
|
||||
const next = rects.reference.width
|
||||
if (prev === next) return prev
|
||||
return next
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
[collisionPadding, isDesktopAboveSearch]
|
||||
);
|
||||
)
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
||||
placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
reference: anchorRef.current ?? undefined,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
setAvailableSize(null)
|
||||
setReferenceWidth(null)
|
||||
return
|
||||
}
|
||||
const raf = requestAnimationFrame(() => void update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||
const raf = requestAnimationFrame(() => void update())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [desktopPlacement, isMobile, update, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setReferenceAtOrigin(false);
|
||||
setReferenceTop(null);
|
||||
return;
|
||||
setReferenceAtOrigin(false)
|
||||
setReferenceTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
const referenceEl = anchorRef.current;
|
||||
const referenceEl = anchorRef.current
|
||||
if (!referenceEl) {
|
||||
setReferenceAtOrigin(false);
|
||||
setReferenceTop(null);
|
||||
return;
|
||||
setReferenceAtOrigin(false)
|
||||
setReferenceTop(null)
|
||||
return
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
referenceEl.measureInWindow((x, y) => {
|
||||
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
||||
setReferenceTop((prev) => (prev === y ? prev : y));
|
||||
});
|
||||
};
|
||||
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
|
||||
setReferenceTop((prev) => (prev === y ? prev : y))
|
||||
})
|
||||
}
|
||||
|
||||
measure();
|
||||
const raf = requestAnimationFrame(measure);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
|
||||
measure()
|
||||
const raf = requestAnimationFrame(measure)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
|
||||
|
||||
const floatingTop = toNumericStyleValue(floatingStyles.top);
|
||||
const floatingLeft = toNumericStyleValue(floatingStyles.left);
|
||||
const floatingTop = toNumericStyleValue(floatingStyles.top)
|
||||
const floatingLeft = toNumericStyleValue(floatingStyles.left)
|
||||
const desktopAboveSearchBottom =
|
||||
isDesktopAboveSearch && referenceTop !== null
|
||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||
: null;
|
||||
: null
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
floatingLeft !== null &&
|
||||
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||
const shouldHideDesktopContent =
|
||||
desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash
|
||||
const desktopPositionStyle = isDesktopAboveSearch
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: desktopAboveSearchBottom ?? 0,
|
||||
}
|
||||
: floatingStyles;
|
||||
: floatingStyles
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (!isMobile) return
|
||||
if (isOpen) {
|
||||
bottomSheetRef.current?.present();
|
||||
bottomSheetRef.current?.present()
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
bottomSheetRef.current?.dismiss()
|
||||
}
|
||||
}, [isOpen, isMobile]);
|
||||
}, [isOpen, isMobile])
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
handleClose();
|
||||
handleClose()
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
)
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[]
|
||||
);
|
||||
)
|
||||
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
|
||||
const sanitizedSearchValue = searchQuery.trim()
|
||||
const showCustomOption = useMemo(
|
||||
() =>
|
||||
shouldShowCustomComboboxOption({
|
||||
@@ -398,7 +405,7 @@ export function Combobox({
|
||||
allowCustomValue,
|
||||
}),
|
||||
[allowCustomValue, options, searchQuery, searchable]
|
||||
);
|
||||
)
|
||||
|
||||
const visibleOptions = useMemo(
|
||||
() =>
|
||||
@@ -420,139 +427,132 @@ export function Combobox({
|
||||
searchQuery,
|
||||
searchable,
|
||||
]
|
||||
);
|
||||
)
|
||||
|
||||
const orderedVisibleOptions = useMemo(
|
||||
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
|
||||
[effectiveOptionsPosition, visibleOptions]
|
||||
);
|
||||
)
|
||||
|
||||
const pinDesktopOptionsToBottom = useCallback(() => {
|
||||
if (isMobile || effectiveOptionsPosition !== "above-search") {
|
||||
return;
|
||||
if (isMobile || effectiveOptionsPosition !== 'above-search') {
|
||||
return
|
||||
}
|
||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
|
||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
|
||||
requestAnimationFrame(() => {
|
||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
|
||||
});
|
||||
}, [effectiveOptionsPosition, isMobile]);
|
||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
|
||||
})
|
||||
}, [effectiveOptionsPosition, isMobile])
|
||||
|
||||
const handleDesktopOptionsContentSizeChange = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
pinDesktopOptionsToBottom();
|
||||
}, [isOpen, pinDesktopOptionsToBottom]);
|
||||
pinDesktopOptionsToBottom()
|
||||
}, [isOpen, pinDesktopOptionsToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
pinDesktopOptionsToBottom();
|
||||
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
|
||||
pinDesktopOptionsToBottom()
|
||||
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
void update();
|
||||
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
|
||||
void update()
|
||||
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!IS_WEB && isMobile) return;
|
||||
if (!isOpen) return
|
||||
if (!IS_WEB && isMobile) return
|
||||
|
||||
if (orderedVisibleOptions.length === 0) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
setActiveIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackIndex = getComboboxFallbackIndex(
|
||||
orderedVisibleOptions.length,
|
||||
effectiveOptionsPosition
|
||||
);
|
||||
)
|
||||
|
||||
if (normalizedSearch) {
|
||||
setActiveIndex(fallbackIndex);
|
||||
return;
|
||||
setActiveIndex(fallbackIndex)
|
||||
return
|
||||
}
|
||||
|
||||
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
|
||||
}, [
|
||||
effectiveOptionsPosition,
|
||||
isMobile,
|
||||
isOpen,
|
||||
normalizedSearch,
|
||||
value,
|
||||
orderedVisibleOptions,
|
||||
]);
|
||||
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
|
||||
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id);
|
||||
handleClose();
|
||||
onSelect(id)
|
||||
handleClose()
|
||||
},
|
||||
[handleClose, onSelect]
|
||||
);
|
||||
)
|
||||
|
||||
const handleSubmitSearch = useCallback(() => {
|
||||
if (showCustomOption) {
|
||||
handleSelect(sanitizedSearchValue);
|
||||
handleSelect(sanitizedSearchValue)
|
||||
}
|
||||
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
||||
}, [handleSelect, sanitizedSearchValue, showCustomOption])
|
||||
|
||||
const handleDesktopKey = useCallback(
|
||||
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
if (!IS_WEB && isMobile) return;
|
||||
(key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
|
||||
if (!isOpen) return
|
||||
if (!IS_WEB && isMobile) return
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
event?.preventDefault();
|
||||
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
||||
event?.preventDefault()
|
||||
setActiveIndex((currentIndex) =>
|
||||
getNextActiveIndex({
|
||||
currentIndex,
|
||||
itemCount: orderedVisibleOptions.length,
|
||||
key,
|
||||
})
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (orderedVisibleOptions.length === 0) return;
|
||||
event?.preventDefault();
|
||||
if (key === 'Enter') {
|
||||
if (orderedVisibleOptions.length === 0) return
|
||||
event?.preventDefault()
|
||||
const index =
|
||||
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||
handleSelect(orderedVisibleOptions[index]!.id);
|
||||
return;
|
||||
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
|
||||
handleSelect(orderedVisibleOptions[index]!.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === "Escape") {
|
||||
event?.preventDefault();
|
||||
handleClose();
|
||||
if (key === 'Escape') {
|
||||
event?.preventDefault()
|
||||
handleClose()
|
||||
}
|
||||
},
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||
);
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !isOpen) return;
|
||||
if (!IS_WEB || !isOpen) return
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
|
||||
return;
|
||||
const key = event.key
|
||||
if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
handleDesktopKey(key, event);
|
||||
};
|
||||
handleDesktopKey(key, event)
|
||||
}
|
||||
|
||||
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handler, true);
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler, true);
|
||||
};
|
||||
}, [handleDesktopKey, isOpen]);
|
||||
window.removeEventListener('keydown', handler, true)
|
||||
}
|
||||
}, [handleDesktopKey, isOpen])
|
||||
|
||||
const searchInput = (
|
||||
<SearchInput
|
||||
@@ -562,7 +562,7 @@ export function Combobox({
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
);
|
||||
)
|
||||
|
||||
const optionsList = (
|
||||
<>
|
||||
@@ -582,17 +582,17 @@ export function Combobox({
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
|
||||
const defaultContent = (
|
||||
<>
|
||||
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
||||
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
|
||||
{searchable ? searchInput : null}
|
||||
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
||||
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
|
||||
</>
|
||||
);
|
||||
)
|
||||
|
||||
const content = children ?? defaultContent;
|
||||
const content = children ?? defaultContent
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -620,18 +620,13 @@ export function Combobox({
|
||||
{content}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return <></>;
|
||||
if (!isOpen) return <></>
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="none"
|
||||
visible={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
|
||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
||||
<Animated.View
|
||||
@@ -641,13 +636,15 @@ export function Combobox({
|
||||
style={[
|
||||
styles.desktopContainer,
|
||||
{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
minWidth: referenceWidth ?? 200,
|
||||
maxWidth: 400,
|
||||
},
|
||||
desktopPositionStyle,
|
||||
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||
typeof availableSize?.height === 'number'
|
||||
? { maxHeight: Math.min(availableSize.height, 400) }
|
||||
: null,
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
@@ -664,7 +661,7 @@ export function Combobox({
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{effectiveOptionsPosition === "above-search" ? (
|
||||
{effectiveOptionsPosition === 'above-search' ? (
|
||||
<ScrollView
|
||||
ref={desktopOptionsScrollRef}
|
||||
contentContainerStyle={[
|
||||
@@ -680,7 +677,7 @@ export function Combobox({
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{searchable ? searchInput : null}
|
||||
{effectiveOptionsPosition === "below-search" ? (
|
||||
{effectiveOptionsPosition === 'below-search' ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -695,13 +692,13 @@ export function Combobox({
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchInputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
@@ -719,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
comboboxItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -744,9 +741,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
comboboxItemTrailingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: "auto",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
comboboxItemContent: {
|
||||
flex: 1,
|
||||
@@ -754,8 +751,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
comboboxItemLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -774,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
borderTopLeftRadius: theme.borderRadius['2xl'],
|
||||
borderTopRightRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
@@ -788,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "left",
|
||||
textAlign: 'left',
|
||||
},
|
||||
comboboxScrollContent: {
|
||||
paddingBottom: theme.spacing[8],
|
||||
@@ -799,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
desktopBackdrop: {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
@@ -810,13 +807,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
},
|
||||
desktopScroll: {
|
||||
flexShrink: 1,
|
||||
@@ -827,6 +824,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
desktopScrollContentAboveSearch: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "flex-end",
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const DEFAULT_MIN_HANDLE_SIZE = 36;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export type VerticalScrollbarGeometryInput = {
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
offset: number;
|
||||
minHandleSize?: number;
|
||||
};
|
||||
|
||||
export type VerticalScrollbarGeometry = {
|
||||
isVisible: boolean;
|
||||
maxScrollOffset: number;
|
||||
handleSize: number;
|
||||
handleOffset: number;
|
||||
maxHandleOffset: number;
|
||||
};
|
||||
|
||||
export function computeVerticalScrollbarGeometry(
|
||||
input: VerticalScrollbarGeometryInput
|
||||
): VerticalScrollbarGeometry {
|
||||
const viewportSize = Number.isFinite(input.viewportSize)
|
||||
? Math.max(0, input.viewportSize)
|
||||
: 0;
|
||||
const contentSize = Number.isFinite(input.contentSize)
|
||||
? Math.max(0, input.contentSize)
|
||||
: 0;
|
||||
const minHandleSize = Number.isFinite(input.minHandleSize)
|
||||
? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE)
|
||||
: DEFAULT_MIN_HANDLE_SIZE;
|
||||
|
||||
const maxScrollOffset = Math.max(0, contentSize - viewportSize);
|
||||
if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) {
|
||||
return {
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
handleSize: 0,
|
||||
handleOffset: 0,
|
||||
maxHandleOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const rawHandleSize = (viewportSize * viewportSize) / contentSize;
|
||||
const handleSize = clamp(rawHandleSize, minHandleSize, viewportSize);
|
||||
const maxHandleOffset = Math.max(0, viewportSize - handleSize);
|
||||
const clampedOffset = clamp(input.offset, 0, maxScrollOffset);
|
||||
const handleOffset =
|
||||
maxScrollOffset > 0
|
||||
? (clampedOffset / maxScrollOffset) * maxHandleOffset
|
||||
: 0;
|
||||
|
||||
return {
|
||||
isVisible: true,
|
||||
maxScrollOffset,
|
||||
handleSize,
|
||||
handleOffset,
|
||||
maxHandleOffset,
|
||||
};
|
||||
}
|
||||
|
||||
export type ScrollOffsetFromDragDeltaInput = {
|
||||
startOffset: number;
|
||||
dragDelta: number;
|
||||
maxScrollOffset: number;
|
||||
maxHandleOffset: number;
|
||||
};
|
||||
|
||||
export function computeScrollOffsetFromDragDelta(
|
||||
input: ScrollOffsetFromDragDeltaInput
|
||||
): number {
|
||||
if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) {
|
||||
return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset));
|
||||
}
|
||||
|
||||
const scrollPerPixel = input.maxScrollOffset / input.maxHandleOffset;
|
||||
const nextOffset = input.startOffset + input.dragDelta * scrollPerPixel;
|
||||
return clamp(nextOffset, 0, input.maxScrollOffset);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
|
||||
describe("computeVerticalScrollbarGeometry", () => {
|
||||
it("returns hidden geometry when content does not overflow", () => {
|
||||
const geometry = computeVerticalScrollbarGeometry({
|
||||
viewportSize: 500,
|
||||
contentSize: 500,
|
||||
offset: 0,
|
||||
minHandleSize: 36,
|
||||
});
|
||||
|
||||
expect(geometry).toEqual({
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
handleSize: 0,
|
||||
handleOffset: 0,
|
||||
maxHandleOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("computes visible geometry when content overflows", () => {
|
||||
const geometry = computeVerticalScrollbarGeometry({
|
||||
viewportSize: 500,
|
||||
contentSize: 2000,
|
||||
offset: 375,
|
||||
minHandleSize: 36,
|
||||
});
|
||||
|
||||
expect(geometry).toEqual({
|
||||
isVisible: true,
|
||||
maxScrollOffset: 1500,
|
||||
handleSize: 125,
|
||||
handleOffset: 93.75,
|
||||
maxHandleOffset: 375,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps handle size to min and offset to bounds", () => {
|
||||
const geometry = computeVerticalScrollbarGeometry({
|
||||
viewportSize: 100,
|
||||
contentSize: 10000,
|
||||
offset: 99999,
|
||||
minHandleSize: 24,
|
||||
});
|
||||
|
||||
expect(geometry).toEqual({
|
||||
isVisible: true,
|
||||
maxScrollOffset: 9900,
|
||||
handleSize: 24,
|
||||
handleOffset: 76,
|
||||
maxHandleOffset: 76,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeScrollOffsetFromDragDelta", () => {
|
||||
it("maps drag distance proportionally to scroll offset", () => {
|
||||
const nextOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: 250,
|
||||
dragDelta: 50,
|
||||
maxScrollOffset: 1000,
|
||||
maxHandleOffset: 200,
|
||||
});
|
||||
|
||||
expect(nextOffset).toBe(500);
|
||||
});
|
||||
|
||||
it("clamps to scroll bounds", () => {
|
||||
const nextOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: 900,
|
||||
dragDelta: 1000,
|
||||
maxScrollOffset: 1000,
|
||||
maxHandleOffset: 200,
|
||||
});
|
||||
|
||||
expect(nextOffset).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
PanResponder,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
};
|
||||
|
||||
function areMetricsEqual(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
|
||||
);
|
||||
}
|
||||
|
||||
export function useWebDesktopScrollbarMetrics() {
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => {
|
||||
setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next));
|
||||
}, []);
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
|
||||
setMetricsIfChanged({
|
||||
offset: Math.max(0, contentOffset.y),
|
||||
viewportSize: Math.max(0, layoutMeasurement.height),
|
||||
contentSize: Math.max(0, contentSize.height),
|
||||
});
|
||||
},
|
||||
[setMetricsIfChanged]
|
||||
);
|
||||
|
||||
const onLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const viewportSize = Math.max(0, event.nativeEvent.layout.height);
|
||||
setMetrics((previous) => {
|
||||
const next = { ...previous, viewportSize };
|
||||
return areMetricsEqual(previous, next) ? previous : next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
const contentSize = Math.max(0, height);
|
||||
setMetrics((previous) => {
|
||||
const next = { ...previous, contentSize };
|
||||
return areMetricsEqual(previous, next) ? previous : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOffset = useCallback((offset: number) => {
|
||||
const clampedOffset = Math.max(0, offset);
|
||||
setMetrics((previous) => {
|
||||
const next = { ...previous, offset: clampedOffset };
|
||||
return areMetricsEqual(previous, next) ? previous : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
onScroll,
|
||||
onLayout,
|
||||
onContentSizeChange,
|
||||
setOffset,
|
||||
};
|
||||
}
|
||||
|
||||
type WebDesktopScrollbarOverlayProps = {
|
||||
enabled: boolean;
|
||||
metrics: ScrollbarMetrics;
|
||||
onScrollToOffset: (offset: number) => void;
|
||||
inverted?: boolean;
|
||||
};
|
||||
|
||||
export function WebDesktopScrollbarOverlay({
|
||||
enabled,
|
||||
metrics,
|
||||
onScrollToOffset,
|
||||
inverted = false,
|
||||
}: WebDesktopScrollbarOverlayProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
|
||||
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
||||
const normalizedOffset = inverted
|
||||
? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset))
|
||||
: clamp(metrics.offset, 0, maxScrollOffset);
|
||||
|
||||
const geometry = useMemo(
|
||||
() =>
|
||||
computeVerticalScrollbarGeometry({
|
||||
viewportSize: metrics.viewportSize,
|
||||
contentSize: metrics.contentSize,
|
||||
offset: normalizedOffset,
|
||||
}),
|
||||
[metrics.contentSize, metrics.viewportSize, normalizedOffset]
|
||||
);
|
||||
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
dragStartOffsetRef.current = normalizedOffset;
|
||||
setIsDragging(true);
|
||||
},
|
||||
onPanResponderMove: (_event, gestureState) => {
|
||||
const nextNormalizedOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta: gestureState.dy,
|
||||
maxScrollOffset: geometry.maxScrollOffset,
|
||||
maxHandleOffset: geometry.maxHandleOffset,
|
||||
});
|
||||
const nextOffset = inverted
|
||||
? geometry.maxScrollOffset - nextNormalizedOffset
|
||||
: nextNormalizedOffset;
|
||||
onScrollToOffset(nextOffset);
|
||||
},
|
||||
onPanResponderRelease: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
}),
|
||||
[
|
||||
geometry.maxHandleOffset,
|
||||
geometry.maxScrollOffset,
|
||||
inverted,
|
||||
normalizedOffset,
|
||||
onScrollToOffset,
|
||||
]
|
||||
);
|
||||
|
||||
if (!enabled || !geometry.isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleOpacity = isDragging ? 0.52 : isHovered ? 0.4 : 0.28;
|
||||
const handleColor =
|
||||
isDragging || isHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
|
||||
return (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<Pressable
|
||||
style={[
|
||||
styles.handle,
|
||||
{
|
||||
top: geometry.handleOffset,
|
||||
height: geometry.handleSize,
|
||||
backgroundColor: handleColor,
|
||||
opacity: handleOpacity,
|
||||
},
|
||||
Platform.OS === "web" &&
|
||||
({ cursor: "grab", touchAction: "none", userSelect: "none" } as any),
|
||||
]}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
{...panResponder.panHandlers}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 10,
|
||||
},
|
||||
handle: {
|
||||
position: "absolute",
|
||||
width: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
|
||||
@@ -19,21 +20,20 @@ export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
|
||||
// Check if running in Tauri desktop app (any OS)
|
||||
function isTauri(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
return "__TAURI__" in window;
|
||||
return getTauri() !== null;
|
||||
}
|
||||
|
||||
// Check if running in Tauri desktop app on macOS
|
||||
function isTauriMac(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
if (!("__TAURI__" in window)) return false;
|
||||
if (getTauri() === null) return false;
|
||||
// Check for macOS via user agent
|
||||
const ua = navigator.userAgent;
|
||||
return ua.includes("Mac OS") || ua.includes("Macintosh");
|
||||
}
|
||||
|
||||
// Cached result - only cache true, keep checking if false (in case __TAURI__ loads later)
|
||||
// Cached result - only cache true, keep checking if false (in case Tauri globals load later)
|
||||
let _isTauriMacCached: boolean | null = null;
|
||||
let _isTauriCached: boolean | null = null;
|
||||
|
||||
|
||||
@@ -351,6 +351,15 @@ export function SessionProvider({
|
||||
const isActive = appState ? appState === "active" : true;
|
||||
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
|
||||
if (!isAwayFromAgent) {
|
||||
console.log(
|
||||
"[OSNotifications] Skipping attention notification: user already focused on agent",
|
||||
{
|
||||
agentId: params.agentId,
|
||||
reason: params.reason,
|
||||
appState,
|
||||
focusedAgentId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +1,290 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { AutocompleteOption } from '@/components/ui/autocomplete'
|
||||
import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
|
||||
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
|
||||
import { useAutocomplete } from './use-autocomplete'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import {
|
||||
applyFileMentionReplacement,
|
||||
findActiveFileMention,
|
||||
type FileMentionRange,
|
||||
} from '@/utils/file-mention-autocomplete'
|
||||
|
||||
interface UseAgentAutocompleteInput {
|
||||
userInput: string;
|
||||
setUserInput: (nextValue: string) => void;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
onAutocompleteApplied?: () => void;
|
||||
userInput: string
|
||||
cursorIndex: number
|
||||
setUserInput: (nextValue: string) => void
|
||||
serverId: string
|
||||
agentId: string
|
||||
draftConfig?: DraftCommandConfig
|
||||
onAutocompleteApplied?: () => void
|
||||
}
|
||||
|
||||
type AgentAutocompleteOption =
|
||||
| (AutocompleteOption & { type: 'command' })
|
||||
| (AutocompleteOption & {
|
||||
type: 'workspace_entry'
|
||||
entryPath: string
|
||||
mention: FileMentionRange
|
||||
})
|
||||
|
||||
interface AgentAutocompleteResult {
|
||||
isVisible: boolean;
|
||||
options: AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
onSelectOption: (option: AutocompleteOption) => void;
|
||||
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||
isVisible: boolean
|
||||
options: AutocompleteOption[]
|
||||
selectedIndex: number
|
||||
isLoading: boolean
|
||||
errorMessage?: string
|
||||
loadingText: string
|
||||
emptyText: string
|
||||
onSelectOption: (option: AutocompleteOption) => void
|
||||
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean
|
||||
}
|
||||
|
||||
interface DirectorySuggestionEntry {
|
||||
path: string
|
||||
kind: 'file' | 'directory'
|
||||
}
|
||||
|
||||
function normalizeDraftCommandConfig(
|
||||
draftConfig?: DraftCommandConfig
|
||||
): DraftCommandConfig | undefined {
|
||||
if (!draftConfig) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cwd = draftConfig.cwd.trim();
|
||||
const cwd = draftConfig.cwd.trim()
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||
const model = draftConfig.model?.trim() ?? "";
|
||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||
const modeId = draftConfig.modeId?.trim() ?? ''
|
||||
const model = draftConfig.model?.trim() ?? ''
|
||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ''
|
||||
return {
|
||||
provider: draftConfig.provider,
|
||||
cwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgentAutocomplete(
|
||||
input: UseAgentAutocompleteInput
|
||||
): AgentAutocompleteResult {
|
||||
function mapDirectorySuggestionsToEntries(payload: {
|
||||
entries?: Array<{ path: string; kind: string }>
|
||||
directories?: string[]
|
||||
}): DirectorySuggestionEntry[] {
|
||||
if (Array.isArray(payload.entries) && payload.entries.length > 0) {
|
||||
return payload.entries.flatMap((entry) => {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry.path !== 'string' ||
|
||||
(entry.kind !== 'file' && entry.kind !== 'directory')
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [{ path: entry.path, kind: entry.kind }]
|
||||
})
|
||||
}
|
||||
|
||||
return (payload.directories ?? []).map((path) => ({
|
||||
path,
|
||||
kind: 'directory' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
||||
const {
|
||||
userInput,
|
||||
cursorIndex,
|
||||
setUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig,
|
||||
onAutocompleteApplied,
|
||||
} = input;
|
||||
} = input
|
||||
|
||||
const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
const filterQuery = showAutocomplete ? userInput.slice(1) : "";
|
||||
const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ')
|
||||
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ''
|
||||
|
||||
const activeFileMention = useMemo(
|
||||
() =>
|
||||
findActiveFileMention({
|
||||
text: userInput,
|
||||
cursorIndex,
|
||||
}),
|
||||
[cursorIndex, userInput]
|
||||
)
|
||||
const showFileAutocomplete = activeFileMention !== null
|
||||
const fileFilterQuery = activeFileMention?.query ?? ''
|
||||
|
||||
const normalizedDraftConfig = useMemo(
|
||||
() => normalizeDraftCommandConfig(draftConfig),
|
||||
[draftConfig]
|
||||
);
|
||||
)
|
||||
|
||||
const isRealAgent = Boolean(agentId) && !agentId.startsWith("__");
|
||||
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig;
|
||||
const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig);
|
||||
const isVisible = showAutocomplete && canLoadOptions;
|
||||
const isRealAgent = Boolean(agentId) && !agentId.startsWith('__')
|
||||
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig
|
||||
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!queryDraftConfig)
|
||||
|
||||
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
||||
const agentCwd = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? ''
|
||||
)
|
||||
const autocompleteCwd = useMemo(() => {
|
||||
if (isRealAgent) {
|
||||
return agentCwd.trim()
|
||||
}
|
||||
return queryDraftConfig?.cwd ?? ''
|
||||
}, [agentCwd, isRealAgent, queryDraftConfig])
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
)
|
||||
|
||||
const mode: 'command' | 'file' | null = showFileAutocomplete
|
||||
? 'file'
|
||||
: showCommandAutocomplete
|
||||
? 'command'
|
||||
: null
|
||||
const isVisible =
|
||||
mode === 'command'
|
||||
? canLoadCommands
|
||||
: mode === 'file'
|
||||
? Boolean(serverId) && autocompleteCwd.length > 0
|
||||
: false
|
||||
|
||||
const {
|
||||
commands,
|
||||
isLoading: isCommandsLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: canLoadOptions,
|
||||
enabled: mode === 'command' && canLoadCommands,
|
||||
draftConfig: queryDraftConfig,
|
||||
});
|
||||
})
|
||||
|
||||
const options = useMemo<AutocompleteOption[]>(() => {
|
||||
const fileSuggestionsQuery = useQuery({
|
||||
queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true],
|
||||
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
|
||||
if (!client) {
|
||||
throw new Error('Daemon client unavailable')
|
||||
}
|
||||
const response = await client.getDirectorySuggestions({
|
||||
cwd: autocompleteCwd,
|
||||
query: fileFilterQuery,
|
||||
limit: 50,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(response.error)
|
||||
}
|
||||
return mapDirectorySuggestionsToEntries(response)
|
||||
},
|
||||
enabled:
|
||||
mode === 'file' &&
|
||||
Boolean(serverId) &&
|
||||
autocompleteCwd.length > 0 &&
|
||||
Boolean(client) &&
|
||||
isConnected,
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
const options = useMemo<AgentAutocompleteOption[]>(() => {
|
||||
if (!isVisible) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
||||
const filterLower = filterQuery.toLowerCase();
|
||||
const matches = commands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(filterLower)
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map((cmd) => ({
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
detail: cmd.argumentHint || undefined,
|
||||
description: cmd.description,
|
||||
}));
|
||||
}, [commands, filterQuery, isVisible]);
|
||||
if (mode === 'command') {
|
||||
const filterLower = commandFilterQuery.toLowerCase()
|
||||
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower))
|
||||
const orderedMatches = orderAutocompleteOptions(matches)
|
||||
return orderedMatches.map((cmd) => ({
|
||||
type: 'command' as const,
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
detail: cmd.argumentHint || undefined,
|
||||
description: cmd.description,
|
||||
kind: 'command',
|
||||
}))
|
||||
}
|
||||
|
||||
if (mode === 'file' && activeFileMention) {
|
||||
return (fileSuggestionsQuery.data ?? []).map((entry) => ({
|
||||
type: 'workspace_entry' as const,
|
||||
id: `${entry.kind}:${entry.path}`,
|
||||
label: entry.path,
|
||||
detail: entry.kind === 'directory' ? 'Directory' : 'File',
|
||||
kind: entry.kind,
|
||||
entryPath: entry.path,
|
||||
mention: activeFileMention,
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode])
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
setUserInput(`/${option.id} `);
|
||||
onAutocompleteApplied?.();
|
||||
const selected = option as AgentAutocompleteOption
|
||||
if (selected.type === 'command') {
|
||||
setUserInput(`/${selected.id} `)
|
||||
onAutocompleteApplied?.()
|
||||
return
|
||||
}
|
||||
|
||||
const nextInput = applyFileMentionReplacement({
|
||||
text: userInput,
|
||||
mention: selected.mention,
|
||||
relativePath: selected.entryPath,
|
||||
})
|
||||
setUserInput(nextInput)
|
||||
onAutocompleteApplied?.()
|
||||
},
|
||||
[onAutocompleteApplied, setUserInput]
|
||||
);
|
||||
[onAutocompleteApplied, setUserInput, userInput]
|
||||
)
|
||||
|
||||
const { selectedIndex, onKeyPress } = useAutocomplete({
|
||||
isVisible,
|
||||
options,
|
||||
query: filterQuery,
|
||||
query: mode === 'command' ? commandFilterQuery : fileFilterQuery,
|
||||
onSelectOption,
|
||||
onEscape: () => setUserInput(""),
|
||||
});
|
||||
onEscape: mode === 'command' ? () => setUserInput('') : undefined,
|
||||
})
|
||||
|
||||
const isLoading =
|
||||
mode === 'command'
|
||||
? isCommandsLoading
|
||||
: mode === 'file'
|
||||
? fileSuggestionsQuery.isPending || fileSuggestionsQuery.isLoading
|
||||
: false
|
||||
const errorMessage =
|
||||
mode === 'command'
|
||||
? isError
|
||||
? (error?.message ?? 'Failed to load')
|
||||
: undefined
|
||||
: mode === 'file'
|
||||
? fileSuggestionsQuery.error instanceof Error
|
||||
? fileSuggestionsQuery.error.message
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...'
|
||||
const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found'
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
options,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
errorMessage: isError ? (error?.message ?? "Failed to load") : undefined,
|
||||
errorMessage,
|
||||
loadingText,
|
||||
emptyText,
|
||||
onSelectOption,
|
||||
onKeyPress,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export interface AudioCaptureConfig {
|
||||
sampleRate?: number;
|
||||
@@ -156,16 +157,33 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isTauri = getTauri() !== null;
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
|
||||
if (!secureContext) {
|
||||
console.log("[AudioRecorder][Web] Microphone preflight", {
|
||||
secureContext,
|
||||
currentOrigin,
|
||||
isTauri,
|
||||
hasMediaDevices:
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === "function",
|
||||
});
|
||||
|
||||
if (!secureContext && !isTauri) {
|
||||
throw new Error(
|
||||
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
|
||||
);
|
||||
}
|
||||
if (!secureContext && isTauri) {
|
||||
console.warn(
|
||||
"[AudioRecorder][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
|
||||
{ currentOrigin }
|
||||
);
|
||||
}
|
||||
|
||||
const options = configRef.current;
|
||||
const constraints: MediaStreamConstraints = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
|
||||
|
||||
@@ -148,13 +149,29 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isTauri = getTauri() !== null;
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
if (!secureContext) {
|
||||
console.log("[DictationAudio][Web] Microphone preflight", {
|
||||
secureContext,
|
||||
currentOrigin,
|
||||
isTauri,
|
||||
hasMediaDevices:
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === "function",
|
||||
});
|
||||
if (!secureContext && !isTauri) {
|
||||
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
|
||||
}
|
||||
if (!secureContext && isTauri) {
|
||||
console.warn(
|
||||
"[DictationAudio][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
|
||||
{ currentOrigin }
|
||||
);
|
||||
}
|
||||
|
||||
const AudioContextCtor = getAudioContextCtor();
|
||||
if (!AudioContextCtor) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { getCurrentTauriWindow } from "@/utils/tauri";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
type ColorScheme = "dark" | "light";
|
||||
@@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme {
|
||||
}
|
||||
|
||||
async function updateMacDockBadge(count?: number) {
|
||||
if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return;
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
@@ -52,10 +53,6 @@ function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
function getFileExtension(path: string): string {
|
||||
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
|
||||
const extensionIndex = normalizedPath.lastIndexOf(".");
|
||||
@@ -72,7 +69,7 @@ function isImagePath(path: string): boolean {
|
||||
function filePathToImageAttachment(path: string): ImageAttachment {
|
||||
const extension = getFileExtension(path);
|
||||
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
||||
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc;
|
||||
const convertFileSrc = getTauri()?.core?.convertFileSrc;
|
||||
const uri =
|
||||
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
|
||||
|
||||
@@ -145,11 +142,11 @@ export function useFileDropZone({
|
||||
}
|
||||
|
||||
async function setupTauriDragDrop(): Promise<boolean> {
|
||||
if (!isTauriEnvironment()) {
|
||||
if (getTauri() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||
const tauriWindow = getCurrentTauriWindow();
|
||||
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { SpeechSegmenter } from "@/voice/speech-segmenter";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export interface SpeechmaticsAudioConfig {
|
||||
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
|
||||
@@ -245,14 +246,30 @@ export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): Speechmat
|
||||
? window.isSecureContext
|
||||
: true;
|
||||
const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isTauri = getTauri() !== null;
|
||||
|
||||
try {
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
}
|
||||
if (!secureContext) {
|
||||
console.log("[Voice][Web] Microphone preflight", {
|
||||
secureContext,
|
||||
currentOrigin,
|
||||
isTauri,
|
||||
hasMediaDevices:
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === "function",
|
||||
});
|
||||
if (!secureContext && !isTauri) {
|
||||
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
|
||||
}
|
||||
if (!secureContext && isTauri) {
|
||||
console.warn(
|
||||
"[Voice][Web] Insecure context reported under Tauri; attempting getUserMedia anyway",
|
||||
{ currentOrigin }
|
||||
);
|
||||
}
|
||||
|
||||
const AudioContextCtor = getAudioContextCtor();
|
||||
if (!AudioContextCtor) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ import Constants from "expo-constants";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
@@ -35,6 +35,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
getDesktopPermissionSnapshot,
|
||||
requestDesktopPermission,
|
||||
shouldShowDesktopPermissionSection,
|
||||
type DesktopPermissionKind,
|
||||
type DesktopPermissionSnapshot,
|
||||
type DesktopPermissionStatus,
|
||||
} from "@/utils/desktop-permissions";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -76,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
@@ -84,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.4,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
@@ -301,39 +307,58 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
audioRowDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
// Footer
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[4],
|
||||
permissionSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
footerAppInfo: {
|
||||
permissionRefreshButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
permissionRefreshButtonDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
permissionRowActions: {
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footerText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
permissionStatusPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 4,
|
||||
minWidth: 88,
|
||||
justifyContent: "center",
|
||||
},
|
||||
footerVersion: {
|
||||
permissionStatusText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
permissionDetailText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
maxWidth: 220,
|
||||
textAlign: "right",
|
||||
},
|
||||
resetButton: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
resetButtonText: {
|
||||
color: theme.colors.palette.red[500],
|
||||
aboutValue: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
// Empty state
|
||||
emptyCard: {
|
||||
@@ -352,26 +377,27 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// Theme toggle
|
||||
themeToggleContainer: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: 4,
|
||||
gap: 4,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: 2,
|
||||
gap: 2,
|
||||
},
|
||||
themeToggleButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
themeToggleButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
themeToggleText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
@@ -417,7 +443,7 @@ export default function SettingsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
const {
|
||||
daemons,
|
||||
isLoading: daemonLoading,
|
||||
@@ -436,7 +462,14 @@ export default function SettingsScreen() {
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
|
||||
useState<DesktopPermissionSnapshot | null>(null);
|
||||
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
|
||||
useState(false);
|
||||
const [requestingDesktopPermission, setRequestingDesktopPermission] =
|
||||
useState<DesktopPermissionKind | null>(null);
|
||||
const isLoading = settingsLoading || daemonLoading;
|
||||
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
||||
@@ -534,6 +567,73 @@ export default function SettingsScreen() {
|
||||
pendingEditReopenServerId,
|
||||
]);
|
||||
|
||||
const refreshDesktopPermissions = useCallback(async () => {
|
||||
if (!showDesktopPermissionSection) return;
|
||||
|
||||
setIsRefreshingDesktopPermissions(true);
|
||||
try {
|
||||
const snapshot = await getDesktopPermissionSnapshot();
|
||||
if (!isMountedRef.current) return;
|
||||
setDesktopPermissionSnapshot(snapshot);
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to load desktop permission status", error);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsRefreshingDesktopPermissions(false);
|
||||
}
|
||||
}
|
||||
}, [showDesktopPermissionSection]);
|
||||
|
||||
const handleRequestDesktopPermission = useCallback(
|
||||
async (kind: DesktopPermissionKind) => {
|
||||
if (!showDesktopPermissionSection) return;
|
||||
|
||||
setRequestingDesktopPermission(kind);
|
||||
try {
|
||||
const status = await requestDesktopPermission({ kind });
|
||||
if (!isMountedRef.current) return;
|
||||
setDesktopPermissionSnapshot((previous) => {
|
||||
const base: DesktopPermissionSnapshot = previous ?? {
|
||||
checkedAt: Date.now(),
|
||||
notifications: {
|
||||
state: "unknown",
|
||||
detail: "Notification status has not been checked yet.",
|
||||
},
|
||||
microphone: {
|
||||
state: "unknown",
|
||||
detail: "Microphone status has not been checked yet.",
|
||||
},
|
||||
};
|
||||
|
||||
return kind === "notifications"
|
||||
? {
|
||||
...base,
|
||||
checkedAt: Date.now(),
|
||||
notifications: status,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
checkedAt: Date.now(),
|
||||
microphone: status,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Settings] Failed to request ${kind} permission`, error);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setRequestingDesktopPermission(null);
|
||||
}
|
||||
await refreshDesktopPermissions();
|
||||
}
|
||||
},
|
||||
[refreshDesktopPermissions, showDesktopPermissionSection]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDesktopPermissionSection) return;
|
||||
void refreshDesktopPermissions();
|
||||
}, [refreshDesktopPermissions, showDesktopPermissionSection]);
|
||||
|
||||
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
||||
if (!editingServerId) return;
|
||||
if (isSavingEdit) return;
|
||||
@@ -590,34 +690,6 @@ export default function SettingsScreen() {
|
||||
[updateSettings]
|
||||
);
|
||||
|
||||
function handleReset() {
|
||||
Alert.alert(
|
||||
"Reset settings",
|
||||
"Are you sure you want to reset all settings to defaults?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Reset",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await resetSettings();
|
||||
Alert.alert(
|
||||
"Settings reset",
|
||||
"All settings have been reset to defaults."
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to reset settings. Please try again."
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const restartConfirmationMessage =
|
||||
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
|
||||
|
||||
@@ -804,55 +876,113 @@ export default function SettingsScreen() {
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Appearance</Text>
|
||||
<View style={styles.themeToggleContainer}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "light" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("light")}
|
||||
>
|
||||
<Sun size={16} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
|
||||
Light
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "dark" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("dark")}
|
||||
>
|
||||
<Moon size={16} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
|
||||
Dark
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "auto" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("auto")}
|
||||
>
|
||||
<Monitor size={16} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
|
||||
System
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={styles.audioCard}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Theme</Text>
|
||||
</View>
|
||||
<View style={styles.themeToggleContainer}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "light" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("light")}
|
||||
>
|
||||
<Sun size={14} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
|
||||
Light
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "dark" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("dark")}
|
||||
>
|
||||
<Moon size={14} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
|
||||
Dark
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "auto" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("auto")}
|
||||
>
|
||||
<Monitor size={14} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
|
||||
System
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerAppInfo}>
|
||||
<Text style={styles.footerText}>Paseo</Text>
|
||||
<Text style={styles.footerVersion}>Version {appVersion}</Text>
|
||||
{showDesktopPermissionSection ? (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.permissionSectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
|
||||
Desktop permissions
|
||||
</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.permissionRefreshButton,
|
||||
(isRefreshingDesktopPermissions ||
|
||||
requestingDesktopPermission !== null) &&
|
||||
styles.permissionRefreshButtonDisabled,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
onPress={() => {
|
||||
void refreshDesktopPermissions();
|
||||
}}
|
||||
disabled={
|
||||
isRefreshingDesktopPermissions ||
|
||||
requestingDesktopPermission !== null
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh desktop permissions"
|
||||
>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.audioCard}>
|
||||
<DesktopPermissionRow
|
||||
title="Notifications"
|
||||
status={desktopPermissionSnapshot?.notifications ?? null}
|
||||
isRequesting={requestingDesktopPermission === "notifications"}
|
||||
onRequest={() => {
|
||||
void handleRequestDesktopPermission("notifications");
|
||||
}}
|
||||
/>
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
showBorder
|
||||
status={desktopPermissionSnapshot?.microphone ?? null}
|
||||
isRequesting={requestingDesktopPermission === "microphone"}
|
||||
onRequest={() => {
|
||||
void handleRequestDesktopPermission("microphone");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* About */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
<View style={styles.audioCard}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Version</Text>
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersion}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable style={styles.resetButton} onPress={handleReset}>
|
||||
<Text style={styles.resetButtonText}>Reset to defaults</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -1228,6 +1358,60 @@ function HostDetailModal({
|
||||
);
|
||||
}
|
||||
|
||||
interface DesktopPermissionRowProps {
|
||||
title: string;
|
||||
status: DesktopPermissionStatus | null;
|
||||
isRequesting: boolean;
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
}
|
||||
|
||||
function DesktopPermissionRow({
|
||||
title,
|
||||
status,
|
||||
isRequesting,
|
||||
showBorder,
|
||||
onRequest,
|
||||
}: DesktopPermissionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const state = status?.state ?? "unknown";
|
||||
const isGranted = state === "granted";
|
||||
const shouldShowDetail =
|
||||
status !== null &&
|
||||
status.detail.trim().length > 0 &&
|
||||
state !== "granted" &&
|
||||
state !== "prompt" &&
|
||||
state !== "not-granted";
|
||||
|
||||
return (
|
||||
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.permissionRowActions}>
|
||||
{isGranted ? (
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={onRequest}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
{isRequesting ? "Requesting..." : "Request"}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowDetail ? (
|
||||
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionRow({
|
||||
connection,
|
||||
latencyMs,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
|
||||
|
||||
export interface ConfirmDialogInput {
|
||||
title: string;
|
||||
@@ -8,26 +9,6 @@ export interface ConfirmDialogInput {
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface TauriDialogAskOptions {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
interface TauriDialogApi {
|
||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface TauriCoreApi {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface TauriGlobalApi {
|
||||
dialog?: TauriDialogApi;
|
||||
core?: TauriCoreApi;
|
||||
}
|
||||
|
||||
interface ConfirmButtonConfig {
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
@@ -67,13 +48,11 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
||||
});
|
||||
}
|
||||
|
||||
function getTauriApi(): TauriGlobalApi | null {
|
||||
function getTauriApi() {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
|
||||
return tauriApi ?? null;
|
||||
return getTauri();
|
||||
}
|
||||
|
||||
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockPlatform = 'web' | 'ios' | 'android'
|
||||
|
||||
type GlobalSnapshot = {
|
||||
Notification: unknown
|
||||
__TAURI__: unknown
|
||||
navigatorDescriptor?: PropertyDescriptor
|
||||
}
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
||||
}
|
||||
|
||||
function setNavigator(value: unknown): void {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
function restoreGlobals(): void {
|
||||
;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__
|
||||
|
||||
if (originalGlobals.navigatorDescriptor) {
|
||||
Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor)
|
||||
} else {
|
||||
delete (globalThis as { navigator?: unknown }).navigator
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModuleForPlatform(platform: MockPlatform) {
|
||||
vi.resetModules()
|
||||
vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
|
||||
return import('./desktop-permissions')
|
||||
}
|
||||
|
||||
describe('desktop-permissions', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('react-native')
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
restoreGlobals()
|
||||
})
|
||||
|
||||
it('shows section only in Tauri web runtime', async () => {
|
||||
const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web')
|
||||
|
||||
expect(shouldShowDesktopPermissionSection()).toBe(false)
|
||||
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = { notification: {} }
|
||||
expect(shouldShowDesktopPermissionSection()).toBe(true)
|
||||
})
|
||||
|
||||
it('reads notification and microphone status', async () => {
|
||||
const isPermissionGranted = vi.fn(async () => false)
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
notification: { isPermissionGranted },
|
||||
}
|
||||
setNavigator({
|
||||
permissions: {
|
||||
query: vi.fn(async () => ({ state: 'granted' })),
|
||||
},
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
|
||||
const snapshot = await getDesktopPermissionSnapshot()
|
||||
|
||||
expect(snapshot.notifications.state).toBe('not-granted')
|
||||
expect(snapshot.microphone.state).toBe('granted')
|
||||
expect(isPermissionGranted).toHaveBeenCalledTimes(1)
|
||||
expect(snapshot.checkedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('queries microphone permission with correct Permissions instance binding', async () => {
|
||||
const permissions = {
|
||||
query(this: unknown, _descriptor: { name: string }) {
|
||||
if (this !== permissions) {
|
||||
throw new TypeError(
|
||||
'Can only call Permissions.query on instances of Permissions'
|
||||
)
|
||||
}
|
||||
return Promise.resolve({ state: 'granted' as const })
|
||||
},
|
||||
}
|
||||
|
||||
setNavigator({
|
||||
permissions,
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
|
||||
const snapshot = await getDesktopPermissionSnapshot()
|
||||
|
||||
expect(snapshot.microphone.state).toBe('granted')
|
||||
})
|
||||
|
||||
it('returns a fallback message when runtime blocks Permissions.query', async () => {
|
||||
setNavigator({
|
||||
permissions: {
|
||||
query: vi.fn(async () => {
|
||||
throw new TypeError(
|
||||
'Can only call Permissions.query on instances of Permissions'
|
||||
)
|
||||
}),
|
||||
},
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
|
||||
const snapshot = await getDesktopPermissionSnapshot()
|
||||
|
||||
expect(snapshot.microphone.state).toBe('unknown')
|
||||
expect(snapshot.microphone.detail).toContain(
|
||||
'Microphone status API is unavailable in this runtime.'
|
||||
)
|
||||
})
|
||||
|
||||
it('requests notification permission via Tauri', async () => {
|
||||
const requestPermission = vi.fn(async () => 'granted')
|
||||
;(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
notification: { requestPermission },
|
||||
}
|
||||
|
||||
const { requestDesktopPermission } = await loadModuleForPlatform('web')
|
||||
const result = await requestDesktopPermission({ kind: 'notifications' })
|
||||
|
||||
expect(result.state).toBe('granted')
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to browser Notification permission when Tauri API is unavailable', async () => {
|
||||
class MockNotification {
|
||||
static permission = 'denied'
|
||||
}
|
||||
;(globalThis as { Notification?: unknown }).Notification = MockNotification
|
||||
setNavigator({})
|
||||
|
||||
const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web')
|
||||
const snapshot = await getDesktopPermissionSnapshot()
|
||||
|
||||
expect(snapshot.notifications.state).toBe('denied')
|
||||
})
|
||||
|
||||
it('requests microphone permission and stops acquired tracks', async () => {
|
||||
const stop = vi.fn()
|
||||
const getUserMedia = vi.fn(async () => ({
|
||||
getTracks: () => [{ stop }],
|
||||
}))
|
||||
setNavigator({
|
||||
permissions: {
|
||||
query: vi.fn(async () => ({ state: 'granted' })),
|
||||
},
|
||||
mediaDevices: {
|
||||
getUserMedia,
|
||||
},
|
||||
})
|
||||
|
||||
const { requestDesktopPermission } = await loadModuleForPlatform('web')
|
||||
const result = await requestDesktopPermission({ kind: 'microphone' })
|
||||
|
||||
expect(result.state).toBe('granted')
|
||||
expect(getUserMedia).toHaveBeenCalledWith({ audio: true })
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('maps microphone request denial to denied status', async () => {
|
||||
setNavigator({
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn(async () => {
|
||||
throw { name: 'NotAllowedError', message: 'denied' }
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { requestDesktopPermission } = await loadModuleForPlatform('web')
|
||||
const result = await requestDesktopPermission({ kind: 'microphone' })
|
||||
|
||||
expect(result.state).toBe('denied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,387 @@
|
||||
import { Platform } from 'react-native'
|
||||
import { getTauri, type TauriNotificationPermission } from '@/utils/tauri'
|
||||
|
||||
export type DesktopPermissionKind = 'notifications' | 'microphone'
|
||||
|
||||
export type DesktopPermissionState =
|
||||
| 'granted'
|
||||
| 'denied'
|
||||
| 'prompt'
|
||||
| 'not-granted'
|
||||
| 'unavailable'
|
||||
| 'unknown'
|
||||
|
||||
export interface DesktopPermissionStatus {
|
||||
state: DesktopPermissionState
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface DesktopPermissionSnapshot {
|
||||
checkedAt: number
|
||||
notifications: DesktopPermissionStatus
|
||||
microphone: DesktopPermissionStatus
|
||||
}
|
||||
|
||||
type NotificationConstructorLike = {
|
||||
permission?: string
|
||||
requestPermission?: () => Promise<string>
|
||||
}
|
||||
|
||||
type MediaStreamTrackLike = {
|
||||
stop?: () => void
|
||||
}
|
||||
|
||||
type MediaStreamLike = {
|
||||
getTracks?: () => MediaStreamTrackLike[]
|
||||
}
|
||||
|
||||
type NavigatorLike = {
|
||||
mediaDevices?: {
|
||||
getUserMedia?: (constraints: { audio: boolean }) => Promise<MediaStreamLike>
|
||||
}
|
||||
permissions?: {
|
||||
query?: (descriptor: { name: string }) => Promise<{ state?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowDesktopPermissionSection(): boolean {
|
||||
return Platform.OS === 'web' && getTauri() !== null
|
||||
}
|
||||
|
||||
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
||||
return input
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function getErrorName(error: unknown): string | null {
|
||||
if (!isObject(error)) {
|
||||
return null
|
||||
}
|
||||
const name = error.name
|
||||
return typeof name === 'string' && name.length > 0 ? name : null
|
||||
}
|
||||
|
||||
function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
|
||||
const message = getErrorMessage(error)
|
||||
if (
|
||||
message.includes('Can only call Permissions.query on instances of Permissions') ||
|
||||
message.includes('Illegal invocation')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
||||
if (Platform.OS !== 'web') {
|
||||
return null
|
||||
}
|
||||
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification
|
||||
if (
|
||||
NotificationConstructor == null ||
|
||||
(typeof NotificationConstructor !== 'function' && typeof NotificationConstructor !== 'object')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return NotificationConstructor as NotificationConstructorLike
|
||||
}
|
||||
|
||||
function getNavigatorLike(): NavigatorLike | null {
|
||||
if (Platform.OS !== 'web') {
|
||||
return null
|
||||
}
|
||||
const webNavigator = (globalThis as { navigator?: unknown }).navigator
|
||||
if (!isObject(webNavigator)) {
|
||||
return null
|
||||
}
|
||||
return webNavigator as NavigatorLike
|
||||
}
|
||||
|
||||
function mapNotificationPermissionString(permission: string): DesktopPermissionStatus {
|
||||
if (permission === 'granted') {
|
||||
return status({
|
||||
state: 'granted',
|
||||
detail: 'Notifications are allowed by the OS.',
|
||||
})
|
||||
}
|
||||
if (permission === 'denied') {
|
||||
return status({
|
||||
state: 'denied',
|
||||
detail: 'Notifications are denied in system settings.',
|
||||
})
|
||||
}
|
||||
if (permission === 'default') {
|
||||
return status({
|
||||
state: 'prompt',
|
||||
detail: 'Notifications have not been granted yet.',
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Unexpected notification permission state: ${permission}`,
|
||||
})
|
||||
}
|
||||
|
||||
function mapTauriNotificationPermissionResult(
|
||||
permission: TauriNotificationPermission
|
||||
): DesktopPermissionStatus {
|
||||
if (permission === 'granted') {
|
||||
return status({
|
||||
state: 'granted',
|
||||
detail: 'Notifications are allowed by the OS.',
|
||||
})
|
||||
}
|
||||
if (permission === 'denied') {
|
||||
return status({
|
||||
state: 'denied',
|
||||
detail: 'Notifications are denied in system settings.',
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'prompt',
|
||||
detail: 'Notifications have not been granted yet.',
|
||||
})
|
||||
}
|
||||
|
||||
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== 'web') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Desktop notification status is only available on web runtime.',
|
||||
})
|
||||
}
|
||||
|
||||
const tauriNotification = getTauri()?.notification
|
||||
if (tauriNotification) {
|
||||
if (typeof tauriNotification.isPermissionGranted !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Tauri notification plugin is missing isPermissionGranted().',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const granted = await tauriNotification.isPermissionGranted()
|
||||
if (granted) {
|
||||
return status({
|
||||
state: 'granted',
|
||||
detail: 'Tauri reports notifications are granted.',
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'not-granted',
|
||||
detail: 'Tauri reports notifications are not granted. Use Request to prompt.',
|
||||
})
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Failed to read notification status: ${getErrorMessage(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor()
|
||||
if (!NotificationConstructor || typeof NotificationConstructor.permission !== 'string') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Web Notification API is unavailable in this environment.',
|
||||
})
|
||||
}
|
||||
|
||||
return mapNotificationPermissionString(NotificationConstructor.permission)
|
||||
}
|
||||
|
||||
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== 'web') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Desktop microphone status is only available on web runtime.',
|
||||
})
|
||||
}
|
||||
|
||||
const webNavigator = getNavigatorLike()
|
||||
if (!webNavigator) {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Navigator is unavailable in this environment.',
|
||||
})
|
||||
}
|
||||
|
||||
const permissionsApi = webNavigator.permissions
|
||||
if (permissionsApi && typeof permissionsApi.query === 'function') {
|
||||
try {
|
||||
const result = await permissionsApi.query({ name: 'microphone' })
|
||||
if (result?.state === 'granted') {
|
||||
return status({
|
||||
state: 'granted',
|
||||
detail: 'Microphone access is granted.',
|
||||
})
|
||||
}
|
||||
if (result?.state === 'denied') {
|
||||
return status({
|
||||
state: 'denied',
|
||||
detail: 'Microphone access is denied in system settings.',
|
||||
})
|
||||
}
|
||||
if (result?.state === 'prompt') {
|
||||
return status({
|
||||
state: 'prompt',
|
||||
detail: 'Microphone permission has not been granted yet.',
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Unexpected microphone permission state: ${result?.state ?? 'unknown'}`,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isPermissionsQueryRuntimeUnsupported(error)) {
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail:
|
||||
'Microphone status API is unavailable in this runtime. Use Request to check access.',
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof webNavigator.mediaDevices?.getUserMedia !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Microphone capture is unavailable in this environment.',
|
||||
})
|
||||
}
|
||||
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: 'Permission status API is unavailable. Use Request to check access.',
|
||||
})
|
||||
}
|
||||
|
||||
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== 'web') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Desktop notification requests are only available on web runtime.',
|
||||
})
|
||||
}
|
||||
|
||||
const tauriNotification = getTauri()?.notification
|
||||
if (tauriNotification) {
|
||||
if (typeof tauriNotification.requestPermission !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Tauri notification plugin is missing requestPermission().',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await tauriNotification.requestPermission()
|
||||
return mapTauriNotificationPermissionResult(permission)
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor()
|
||||
if (!NotificationConstructor || typeof NotificationConstructor.requestPermission !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Web Notification API requestPermission() is unavailable.',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await NotificationConstructor.requestPermission()
|
||||
return mapNotificationPermissionString(permission)
|
||||
} catch (error) {
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== 'web') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Desktop microphone requests are only available on web runtime.',
|
||||
})
|
||||
}
|
||||
|
||||
const webNavigator = getNavigatorLike()
|
||||
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== 'function') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'Microphone capture API is unavailable in this environment.',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const tracks = stream && typeof stream.getTracks === 'function' ? stream.getTracks() : []
|
||||
tracks.forEach((track) => {
|
||||
if (typeof track.stop === 'function') {
|
||||
track.stop()
|
||||
}
|
||||
})
|
||||
return await getMicrophonePermissionStatus()
|
||||
} catch (error) {
|
||||
const errorName = getErrorName(error)
|
||||
if (errorName === 'NotAllowedError' || errorName === 'PermissionDeniedError') {
|
||||
return status({
|
||||
state: 'denied',
|
||||
detail: 'Microphone permission was denied by the user or system.',
|
||||
})
|
||||
}
|
||||
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
|
||||
return status({
|
||||
state: 'unavailable',
|
||||
detail: 'No microphone device was found.',
|
||||
})
|
||||
}
|
||||
return status({
|
||||
state: 'unknown',
|
||||
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestDesktopPermission(input: {
|
||||
kind: DesktopPermissionKind
|
||||
}): Promise<DesktopPermissionStatus> {
|
||||
if (input.kind === 'notifications') {
|
||||
return await requestNotificationPermissionStatus()
|
||||
}
|
||||
return await requestMicrophonePermissionStatus()
|
||||
}
|
||||
|
||||
export async function getDesktopPermissionSnapshot(): Promise<DesktopPermissionSnapshot> {
|
||||
const [notifications, microphone] = await Promise.all([
|
||||
getNotificationPermissionStatus(),
|
||||
getMicrophonePermissionStatus(),
|
||||
])
|
||||
|
||||
return {
|
||||
checkedAt: Date.now(),
|
||||
notifications,
|
||||
microphone,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
|
||||
|
||||
describe('findActiveFileMention', () => {
|
||||
it('detects mentions at the start of input', () => {
|
||||
const mention = findActiveFileMention({
|
||||
text: '@src/components',
|
||||
cursorIndex: '@src/components'.length,
|
||||
})
|
||||
expect(mention).toEqual({
|
||||
start: 0,
|
||||
end: '@src/components'.length,
|
||||
query: 'src/components',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects mentions in the middle of input using cursor position', () => {
|
||||
const text = 'read "@src/com" before merging'
|
||||
const cursorIndex = text.indexOf('"') + 9
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex,
|
||||
})
|
||||
expect(mention).toEqual({
|
||||
start: text.indexOf('@'),
|
||||
end: cursorIndex,
|
||||
query: 'src/com',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when cursor is outside the mention token', () => {
|
||||
const text = 'please review @src/components now'
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
})
|
||||
expect(mention).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyFileMentionReplacement', () => {
|
||||
it('replaces only the active @query segment with a quoted relative path', () => {
|
||||
const text = 'open @src/com next'
|
||||
const next = applyFileMentionReplacement({
|
||||
text,
|
||||
mention: { start: 5, end: 13, query: 'src/com' },
|
||||
relativePath: 'src/components/chat.tsx',
|
||||
})
|
||||
expect(next).toBe('open "src/components/chat.tsx" next')
|
||||
})
|
||||
|
||||
it('escapes double quotes in replacement path', () => {
|
||||
const text = '@foo'
|
||||
const next = applyFileMentionReplacement({
|
||||
text,
|
||||
mention: { start: 0, end: 4, query: 'foo' },
|
||||
relativePath: 'src/"quoted".ts',
|
||||
})
|
||||
expect(next).toBe('"src/\\"quoted\\".ts"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface FileMentionRange {
|
||||
start: number
|
||||
end: number
|
||||
query: string
|
||||
}
|
||||
|
||||
interface FindActiveFileMentionInput {
|
||||
text: string
|
||||
cursorIndex: number
|
||||
}
|
||||
|
||||
interface ApplyFileMentionReplacementInput {
|
||||
text: string
|
||||
mention: FileMentionRange
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
|
||||
|
||||
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
|
||||
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
|
||||
const beforeCursor = input.text.slice(0, clampedCursor)
|
||||
|
||||
for (
|
||||
let atIndex = beforeCursor.lastIndexOf('@');
|
||||
atIndex >= 0;
|
||||
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
|
||||
) {
|
||||
const query = beforeCursor.slice(atIndex + 1)
|
||||
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
start: atIndex,
|
||||
end: clampedCursor,
|
||||
query,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
|
||||
const safePath = input.relativePath.replace(/"/g, '\\"')
|
||||
const before = input.text.slice(0, input.mention.start)
|
||||
const after = input.text.slice(input.mention.end)
|
||||
return `${before}"${safePath}"${after}`
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
|
||||
interface TauriWindowWithOpener {
|
||||
__TAURI__?: {
|
||||
opener?: {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && getIsTauri()) {
|
||||
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
const opener = getTauri()?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
|
||||
@@ -18,6 +18,7 @@ type GlobalSnapshot = {
|
||||
dispatchEvent: unknown;
|
||||
focus: unknown;
|
||||
location: unknown;
|
||||
__TAURI__: unknown;
|
||||
};
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
@@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = {
|
||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||
focus: (globalThis as { focus?: unknown }).focus,
|
||||
location: (globalThis as { location?: unknown }).location,
|
||||
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||
@@ -40,6 +42,7 @@ function restoreGlobals(): void {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
|
||||
}
|
||||
|
||||
describe("sendOsNotification", () => {
|
||||
@@ -163,4 +166,33 @@ describe("sendOsNotification", () => {
|
||||
|
||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
||||
});
|
||||
|
||||
it("uses Tauri notification module when available", async () => {
|
||||
const isPermissionGranted = vi.fn(async () => false);
|
||||
const requestPermission = vi.fn(async () => "granted");
|
||||
const sendNotification = vi.fn(async () => undefined);
|
||||
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
notification: {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
},
|
||||
};
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(isPermissionGranted).toHaveBeenCalledTimes(1);
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { buildNotificationRoute } from "./notification-routing";
|
||||
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
|
||||
function getTauriNotificationModule(): TauriNotificationApi | null {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
return getTauri()?.notification ?? null;
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
permission: string;
|
||||
requestPermission?: () => Promise<string>;
|
||||
@@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriNotification = getTauriNotificationModule();
|
||||
if (tauriNotification) {
|
||||
return await ensureTauriNotificationPermission(tauriNotification);
|
||||
}
|
||||
|
||||
return await ensureNotificationPermission();
|
||||
}
|
||||
|
||||
async function ensureTauriNotificationPermission(
|
||||
notificationModule: TauriNotificationApi
|
||||
): Promise<boolean> {
|
||||
if (typeof notificationModule.isPermissionGranted === "function") {
|
||||
try {
|
||||
const granted = await notificationModule.isPermissionGranted();
|
||||
if (granted) {
|
||||
console.log("[OSNotifications][Tauri] Permission already granted");
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to check notification permission",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof notificationModule.requestPermission !== "function") {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] notification.requestPermission is unavailable"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await notificationModule.requestPermission();
|
||||
console.log("[OSNotifications][Tauri] requestPermission result:", result);
|
||||
return result === "granted";
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] Failed to request notification permission",
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTauriNotification(
|
||||
payload: OsNotificationPayload,
|
||||
notificationModule: TauriNotificationApi
|
||||
): Promise<boolean> {
|
||||
if (typeof notificationModule.sendNotification !== "function") {
|
||||
console.warn(
|
||||
"[OSNotifications][Tauri] notification.sendNotification is unavailable"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const granted = await ensureTauriNotificationPermission(notificationModule);
|
||||
if (!granted) {
|
||||
console.log("[OSNotifications][Tauri] Permission not granted");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await notificationModule.sendNotification({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[OSNotifications][Tauri] Failed to send notification", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
||||
@@ -127,12 +215,28 @@ export async function sendOsNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauriNotification = getTauriNotificationModule();
|
||||
if (tauriNotification) {
|
||||
console.log("[OSNotifications] Using Tauri notification module");
|
||||
return await sendTauriNotification(payload, tauriNotification);
|
||||
}
|
||||
|
||||
const NotificationConstructor = getWebNotificationConstructor();
|
||||
if (!NotificationConstructor) {
|
||||
console.log(
|
||||
"[OSNotifications][Web] Notification constructor unavailable",
|
||||
typeof window !== "undefined" && window.location
|
||||
? window.location.origin
|
||||
: "unknown-origin"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const granted = await ensureNotificationPermission();
|
||||
const granted = await ensureOsNotificationPermission();
|
||||
if (!granted) {
|
||||
console.log(
|
||||
"[OSNotifications][Web] Permission not granted:",
|
||||
NotificationConstructor.permission
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const notification = new NotificationConstructor(payload.title, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
type TauriWebSocketMessage =
|
||||
| { type: "Text"; data: string }
|
||||
@@ -29,15 +30,8 @@ function toTauriOutgoingMessage(
|
||||
return Array.from(data);
|
||||
}
|
||||
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
const ws = (window as any).__TAURI__?.websocket;
|
||||
const ws = getTauri()?.websocket;
|
||||
if (ws && typeof ws.connect === "function") {
|
||||
return ws as TauriWebSocketModule;
|
||||
}
|
||||
@@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
}
|
||||
|
||||
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
if (getTauri() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
|
||||
}
|
||||
if (!module) {
|
||||
throw new Error(
|
||||
"Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
"Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
||||
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
|
||||
let tauriWindow: any = null;
|
||||
|
||||
// Runtime check for Tauri environment
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" &&
|
||||
(window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
async function getTauriWindow() {
|
||||
if (tauriWindow) return tauriWindow;
|
||||
|
||||
@@ -19,15 +14,18 @@ async function getTauriWindow() {
|
||||
}
|
||||
|
||||
try {
|
||||
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs on `window.__TAURI__`.
|
||||
// When `app.withGlobalTauri` is enabled, Tauri exposes its JS APIs via getTauri().
|
||||
// We must use that here because importing `@tauri-apps/api/*` would be bundled into
|
||||
// the native (Hermes) builds and break parsing/runtime on mobile.
|
||||
const tauri = (window as any).__TAURI__ as any;
|
||||
const tauri = getTauri();
|
||||
if (!tauri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the public global window module.
|
||||
const windowModule = tauri?.window;
|
||||
if (windowModule && typeof windowModule.getCurrentWindow === "function") {
|
||||
tauriWindow = windowModule.getCurrentWindow();
|
||||
const publicWindow = getCurrentTauriWindow();
|
||||
if (publicWindow) {
|
||||
tauriWindow = publicWindow;
|
||||
return tauriWindow;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export type TauriNotificationPermission = "granted" | "denied" | "default";
|
||||
|
||||
export interface TauriDialogAskOptions {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
export interface TauriDialogApi {
|
||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TauriCoreApi {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
convertFileSrc?: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface TauriEventApi {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (event: unknown) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export interface TauriWindowApi {
|
||||
label?: string;
|
||||
startDragging?: () => Promise<void>;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
setBadgeCount?: (count?: number) => Promise<void>;
|
||||
onDragDropEvent?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void
|
||||
) => Promise<() => void> | (() => void);
|
||||
}
|
||||
|
||||
export interface TauriWindowModule {
|
||||
getCurrentWindow?: () => TauriWindowApi;
|
||||
}
|
||||
|
||||
export interface TauriOpenerApi {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TauriNotificationApi {
|
||||
isPermissionGranted?: () => Promise<boolean>;
|
||||
requestPermission?: () => Promise<TauriNotificationPermission>;
|
||||
sendNotification?: (
|
||||
payload: string | { title: string; body?: string }
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TauriWebSocketApi {
|
||||
connect?: (url: string, config?: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface TauriApi {
|
||||
core?: TauriCoreApi;
|
||||
dialog?: TauriDialogApi;
|
||||
event?: TauriEventApi;
|
||||
notification?: TauriNotificationApi;
|
||||
opener?: TauriOpenerApi;
|
||||
websocket?: TauriWebSocketApi;
|
||||
window?: TauriWindowModule;
|
||||
}
|
||||
|
||||
export function getTauri(): TauriApi | null {
|
||||
const tauri = (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||
if (!tauri || typeof tauri !== "object") {
|
||||
return null;
|
||||
}
|
||||
return tauri as TauriApi;
|
||||
}
|
||||
|
||||
export function isTauriEnvironment(): boolean {
|
||||
return getTauri() !== null;
|
||||
}
|
||||
|
||||
export function getCurrentTauriWindow(): TauriWindowApi | null {
|
||||
const getter = getTauri()?.window?.getCurrentWindow;
|
||||
if (typeof getter !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return getter() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Generated
+69
-2
@@ -2028,6 +2028,18 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@@ -2164,6 +2176,20 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@@ -2512,7 +2538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "paseo"
|
||||
version = "0.1.0"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -2521,6 +2547,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-websocket",
|
||||
]
|
||||
@@ -2708,7 +2735,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -2858,6 +2885,15 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -4035,6 +4071,25 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
@@ -4178,6 +4233,18 @@ dependencies = [
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "paseo"
|
||||
version = "0.1.0"
|
||||
version = "0.1.13"
|
||||
description = "Paseo Desktop"
|
||||
authors = ["moboudra"]
|
||||
license = "MIT"
|
||||
@@ -28,5 +28,7 @@ log = "0.4"
|
||||
tauri = { version = "2.9.5", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -7,9 +7,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>Paseo</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>0.1.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<string>0.1.13</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
|
||||
</dict>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"core:window:allow-set-badge-count",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"dialog:default",
|
||||
"notification:default",
|
||||
"opener:default",
|
||||
"websocket:default"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::menu::AboutMetadata;
|
||||
use tauri::Manager;
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
@@ -20,6 +22,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.setup(|app| {
|
||||
@@ -38,6 +41,30 @@ pub fn run() {
|
||||
// responder-chain shortcuts across the whole app.
|
||||
let menu = Menu::default(app.handle())?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app_menu = menu.items()?.into_iter().find_map(|item| match item {
|
||||
MenuItemKind::Submenu(submenu) => Some(submenu),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
if let Some(submenu) = app_menu {
|
||||
// Tauri's default about item sets only `version`, which macOS renders as
|
||||
// "Version <plist short> (<version>)". Set only `short_version` instead.
|
||||
let about_metadata = AboutMetadata {
|
||||
name: Some(app.package_info().name.clone()),
|
||||
short_version: Some(app.package_info().version.to_string()),
|
||||
copyright: app.config().bundle.copyright.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let about = PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
|
||||
|
||||
if submenu.remove_at(0)?.is_some() {
|
||||
submenu.insert(&about, 0)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In")
|
||||
.accelerator("CmdOrCtrl+=")
|
||||
.build(app)?;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
// `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle),
|
||||
// so we must embed an Info.plist containing `NSMicrophoneUsageDescription` for WebKit
|
||||
// microphone access to trigger the OS permission prompt in dev.
|
||||
// so we must embed an Info.plist containing usage descriptions for WebKit media
|
||||
// permission prompts in dev.
|
||||
#[cfg(all(target_os = "macos", debug_assertions))]
|
||||
tauri::embed_plist::embed_info_plist!("../Info.plist");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Paseo",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.13",
|
||||
"identifier": "dev.paseo.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../../app/dist",
|
||||
@@ -20,7 +20,10 @@
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 16, "y": 22 }
|
||||
"trafficLightPosition": {
|
||||
"x": 16,
|
||||
"y": 22
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -38,6 +41,7 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1083
-1159
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,7 @@ describe("claude agent commands E2E", () => {
|
||||
// These are skills that come from CLAUDE.md configurations
|
||||
// At minimum we should have some commands available
|
||||
expect(commandNames.length).toBeGreaterThan(0);
|
||||
expect(commandNames).toContain("rewind");
|
||||
}, 60000);
|
||||
|
||||
test("returns error for non-existent agent", async () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ const hasClaudeCredentials =
|
||||
// Should have at least one command (skills are loaded from user/project settings)
|
||||
// The exact commands depend on what skills are configured
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
expect(commandNames).toContain("rewind");
|
||||
|
||||
await session.close();
|
||||
}, 30000);
|
||||
|
||||
@@ -789,6 +789,63 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
120_000
|
||||
);
|
||||
|
||||
test(
|
||||
"supports /rewind by reverting the latest file changes",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
const filePath = path.join(cwd, "rewind-target.txt");
|
||||
const tokenA = `REWIND_A_${Date.now().toString(36)}`;
|
||||
const tokenB = `REWIND_B_${Date.now().toString(36)}`;
|
||||
|
||||
const runPrompt = async (prompt: string): Promise<void> => {
|
||||
for await (const event of session.stream(prompt)) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "turn_failed") {
|
||||
throw new Error(event.error);
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await runPrompt(
|
||||
[
|
||||
"Create a file named rewind-target.txt in the current directory.",
|
||||
`Set the file content to exactly: ${tokenA}`,
|
||||
"Do not add extra text or commentary.",
|
||||
].join(" ")
|
||||
);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toContain(tokenA);
|
||||
|
||||
await runPrompt(
|
||||
[
|
||||
"Edit rewind-target.txt in place.",
|
||||
`Replace the entire file content with exactly: ${tokenB}`,
|
||||
"Do not add extra text or commentary.",
|
||||
].join(" ")
|
||||
);
|
||||
expect(readFileSync(filePath, "utf8")).toContain(tokenB);
|
||||
|
||||
const rewind = await session.run("/rewind");
|
||||
expect(rewind.finalText.toLowerCase()).toContain("rewound");
|
||||
|
||||
const contentAfterRewind = readFileSync(filePath, "utf8");
|
||||
expect(contentAfterRewind).toContain(tokenA);
|
||||
expect(contentAfterRewind).not.toContain(tokenB);
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240_000
|
||||
);
|
||||
|
||||
test(
|
||||
"resumes a persisted session with context preserved",
|
||||
async () => {
|
||||
|
||||
@@ -212,6 +212,21 @@ const VALID_CLAUDE_MODES = new Set(
|
||||
DEFAULT_MODES.map((mode) => mode.id)
|
||||
);
|
||||
|
||||
const REWIND_COMMAND_NAME = "rewind";
|
||||
const REWIND_COMMAND: AgentSlashCommand = {
|
||||
name: REWIND_COMMAND_NAME,
|
||||
description: "Rewind tracked files to a previous user message",
|
||||
argumentHint: "[user_message_uuid]",
|
||||
};
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
type SlashCommandInvocation = {
|
||||
commandName: string;
|
||||
args?: string;
|
||||
rawInput: string;
|
||||
};
|
||||
|
||||
// Orchestrator instructions moved to shared module.
|
||||
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
||||
|
||||
@@ -672,6 +687,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private activeSidechains = new Map<string, string>();
|
||||
private compacting = false;
|
||||
private queryRestartNeeded = false;
|
||||
private userMessageIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
config: ClaudeAgentConfig,
|
||||
@@ -787,6 +803,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// Reset cancel flag at the start of each turn to prevent stale state from previous turns
|
||||
this.turnCancelRequested = false;
|
||||
|
||||
const slashCommand = this.resolveSlashCommandInvocation(prompt);
|
||||
if (slashCommand?.commandName === REWIND_COMMAND_NAME) {
|
||||
yield* this.streamRewindCommand(slashCommand);
|
||||
return;
|
||||
}
|
||||
|
||||
const sdkMessage = this.toSdkUserMessage(prompt);
|
||||
const queue = new Pushable<AgentStreamEvent>();
|
||||
this.eventQueue = queue;
|
||||
@@ -1007,11 +1029,284 @@ class ClaudeAgentSession implements AgentSession {
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
const q = await this.ensureQuery();
|
||||
const commands = await q.supportedCommands();
|
||||
return commands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
argumentHint: cmd.argumentHint,
|
||||
}));
|
||||
const commandMap = new Map<string, AgentSlashCommand>();
|
||||
for (const cmd of commands) {
|
||||
if (!commandMap.has(cmd.name)) {
|
||||
commandMap.set(cmd.name, {
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
argumentHint: cmd.argumentHint,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!commandMap.has(REWIND_COMMAND_NAME)) {
|
||||
commandMap.set(REWIND_COMMAND_NAME, REWIND_COMMAND);
|
||||
}
|
||||
return Array.from(commandMap.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSlashCommandInvocation(
|
||||
prompt: AgentPromptInput
|
||||
): SlashCommandInvocation | null {
|
||||
if (typeof prompt !== "string") {
|
||||
return null;
|
||||
}
|
||||
const parsed = this.parseSlashCommandInput(prompt);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return parsed.commandName === REWIND_COMMAND_NAME ? parsed : null;
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): SlashCommandInvocation | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
const withoutPrefix = trimmed.slice(1);
|
||||
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
|
||||
const commandName =
|
||||
firstWhitespaceIdx === -1
|
||||
? withoutPrefix
|
||||
: withoutPrefix.slice(0, firstWhitespaceIdx);
|
||||
if (!commandName || commandName.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
const rawArgs =
|
||||
firstWhitespaceIdx === -1
|
||||
? ""
|
||||
: withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
|
||||
return rawArgs.length > 0
|
||||
? { commandName, args: rawArgs, rawInput: trimmed }
|
||||
: { commandName, rawInput: trimmed };
|
||||
}
|
||||
|
||||
private async *streamRewindCommand(
|
||||
invocation: SlashCommandInvocation
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: "claude" };
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: invocation.rawInput },
|
||||
};
|
||||
|
||||
try {
|
||||
const rewindAttempt = await this.attemptRewind(invocation.args);
|
||||
if (!rewindAttempt.messageId || !rewindAttempt.result) {
|
||||
yield {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error:
|
||||
rewindAttempt.error ??
|
||||
"No prior user message available to rewind. Use /rewind <user_message_uuid>.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: this.buildRewindSuccessMessage(
|
||||
rewindAttempt.messageId,
|
||||
rewindAttempt.result
|
||||
),
|
||||
},
|
||||
};
|
||||
yield { type: "turn_completed", provider: "claude" };
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to rewind tracked files",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildRewindSuccessMessage(
|
||||
targetUserMessageId: string,
|
||||
rewindResult: { filesChanged?: string[]; insertions?: number; deletions?: number }
|
||||
): string {
|
||||
const fileCount = Array.isArray(rewindResult.filesChanged)
|
||||
? rewindResult.filesChanged.length
|
||||
: undefined;
|
||||
const stats: string[] = [];
|
||||
if (typeof fileCount === "number") {
|
||||
stats.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (typeof rewindResult.insertions === "number") {
|
||||
stats.push(`${rewindResult.insertions} insertions`);
|
||||
}
|
||||
if (typeof rewindResult.deletions === "number") {
|
||||
stats.push(`${rewindResult.deletions} deletions`);
|
||||
}
|
||||
if (stats.length > 0) {
|
||||
return `Rewound tracked files to message ${targetUserMessageId} (${stats.join(", ")}).`;
|
||||
}
|
||||
return `Rewound tracked files to message ${targetUserMessageId}.`;
|
||||
}
|
||||
|
||||
private async attemptRewind(
|
||||
args: string | undefined
|
||||
): Promise<{
|
||||
messageId: string | null;
|
||||
result?: { filesChanged?: string[]; insertions?: number; deletions?: number };
|
||||
error?: string;
|
||||
}> {
|
||||
if (typeof args === "string" && args.trim().length > 0) {
|
||||
const candidate = args.trim().split(/\s+/)[0] ?? "";
|
||||
if (!UUID_PATTERN.test(candidate)) {
|
||||
return {
|
||||
messageId: null,
|
||||
error:
|
||||
"Invalid message UUID. Usage: /rewind <user_message_uuid> or /rewind",
|
||||
};
|
||||
}
|
||||
const rewindResult = await this.rewindFilesOnce(candidate);
|
||||
if (rewindResult.canRewind) {
|
||||
return { messageId: candidate, result: rewindResult };
|
||||
}
|
||||
return {
|
||||
messageId: null,
|
||||
error:
|
||||
rewindResult.error ??
|
||||
`No file checkpoint found for message ${candidate}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const candidates = this.getRewindCandidateUserMessageIds();
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
messageId: null,
|
||||
error:
|
||||
"No prior user message available to rewind. Use /rewind <user_message_uuid>.",
|
||||
};
|
||||
}
|
||||
|
||||
let lastError: string | undefined;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const rewindResult = await this.rewindFilesOnce(candidate);
|
||||
if (rewindResult.canRewind) {
|
||||
return { messageId: candidate, result: rewindResult };
|
||||
}
|
||||
if (rewindResult.error) {
|
||||
lastError = rewindResult.error;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to rewind tracked files.";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: null,
|
||||
error:
|
||||
lastError ??
|
||||
"No rewind checkpoints are currently available for this session.",
|
||||
};
|
||||
}
|
||||
|
||||
private async rewindFilesOnce(messageId: string): Promise<{
|
||||
canRewind: boolean;
|
||||
error?: string;
|
||||
filesChanged?: string[];
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
}> {
|
||||
try {
|
||||
const query = await this.ensureFreshQuery();
|
||||
return await query.rewindFiles(messageId, { dryRun: false });
|
||||
} catch (error) {
|
||||
// The Claude SDK transport can close after a rewind call.
|
||||
// If that happens, mark the query stale so a follow-up attempt uses a fresh query.
|
||||
this.queryRestartNeeded = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureFreshQuery(): Promise<Query> {
|
||||
if (this.query) {
|
||||
this.queryRestartNeeded = true;
|
||||
}
|
||||
return this.ensureQuery();
|
||||
}
|
||||
|
||||
private getRewindCandidateUserMessageIds(): string[] {
|
||||
const candidates: string[] = [];
|
||||
const pushUnique = (value: string | null | undefined) => {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
!candidates.includes(value)
|
||||
) {
|
||||
candidates.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
const historyIds = this.readUserMessageIdsFromHistoryFile();
|
||||
for (let idx = historyIds.length - 1; idx >= 0; idx -= 1) {
|
||||
pushUnique(historyIds[idx]);
|
||||
}
|
||||
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
|
||||
const item = this.persistedHistory[idx];
|
||||
if (item?.type === "user_message") {
|
||||
pushUnique(item.messageId);
|
||||
}
|
||||
}
|
||||
for (let idx = this.userMessageIds.length - 1; idx >= 0; idx -= 1) {
|
||||
pushUnique(this.userMessageIds[idx]);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private readUserMessageIdsFromHistoryFile(): string[] {
|
||||
if (!this.claudeSessionId) {
|
||||
return [];
|
||||
}
|
||||
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
|
||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const ids: string[] = [];
|
||||
const content = fs.readFileSync(historyPath, "utf8");
|
||||
for (const line of content.split(/\n+/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed);
|
||||
if (entry?.type === "user" && typeof entry.uuid === "string") {
|
||||
ids.push(entry.uuid);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private rememberUserMessageId(messageId: string | null | undefined): void {
|
||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||
return;
|
||||
}
|
||||
const last = this.userMessageIds[this.userMessageIds.length - 1];
|
||||
if (last === messageId) {
|
||||
return;
|
||||
}
|
||||
this.userMessageIds.push(messageId);
|
||||
}
|
||||
|
||||
private async primeSelectableModelIds(query: Query): Promise<void> {
|
||||
@@ -1095,6 +1390,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
MCP_TIMEOUT: "600000",
|
||||
MCP_TOOL_TIMEOUT: "600000",
|
||||
},
|
||||
// Required for provider-level /rewind support.
|
||||
enableFileCheckpointing: true,
|
||||
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||
// resume that session to continue the conversation history.
|
||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
||||
@@ -1174,6 +1471,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
content.push({ type: "text", text: prompt });
|
||||
}
|
||||
|
||||
const messageId = randomUUID();
|
||||
this.rememberUserMessageId(messageId);
|
||||
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
@@ -1181,6 +1481,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: messageId,
|
||||
session_id: this.claudeSessionId ?? "",
|
||||
};
|
||||
}
|
||||
@@ -1426,17 +1727,34 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.compacting = false;
|
||||
break;
|
||||
}
|
||||
const messageId =
|
||||
typeof message.uuid === "string" && message.uuid.length > 0
|
||||
? message.uuid
|
||||
: undefined;
|
||||
this.rememberUserMessageId(messageId);
|
||||
const content = message.message?.content;
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
// String content from user messages (e.g., local command output)
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: { type: "user_message", text: content },
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: content,
|
||||
...(messageId ? { messageId } : {}),
|
||||
},
|
||||
provider: "claude",
|
||||
});
|
||||
} else if (Array.isArray(content)) {
|
||||
const timelineItems = this.mapBlocksToTimeline(content, { turnContext });
|
||||
for (const item of timelineItems) {
|
||||
if (item.type === "user_message" && messageId && !item.messageId) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: { ...item, messageId },
|
||||
provider: "claude",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
}
|
||||
@@ -1723,6 +2041,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (entry.isSidechain) {
|
||||
continue;
|
||||
}
|
||||
if (entry.type === "user" && typeof entry.uuid === "string") {
|
||||
this.rememberUserMessageId(entry.uuid);
|
||||
}
|
||||
const items = this.convertHistoryEntry(entry);
|
||||
if (items.length > 0) {
|
||||
timeline.push(...items);
|
||||
@@ -2300,9 +2621,14 @@ export function convertClaudeHistoryEntry(
|
||||
if (entry.type === "user") {
|
||||
const text = extractUserMessageText(content);
|
||||
if (text) {
|
||||
const messageId =
|
||||
typeof entry.uuid === "string" && entry.uuid.length > 0
|
||||
? entry.uuid
|
||||
: undefined;
|
||||
timeline.push({
|
||||
type: "user_message",
|
||||
text,
|
||||
...(messageId ? { messageId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+72
-1
@@ -12,6 +12,7 @@ import type {
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
@@ -65,6 +66,7 @@ function buildSdkQueryMock() {
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +93,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
let previousClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
sdkMocks.query.mockImplementation(() => buildSdkQueryMock());
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
const mock = buildSdkQueryMock();
|
||||
sdkMocks.lastQuery = mock;
|
||||
return mock;
|
||||
});
|
||||
|
||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-"));
|
||||
cwd = path.join(tempRoot, "repo");
|
||||
@@ -107,6 +113,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "history-user-uuid",
|
||||
sessionId: "history-session",
|
||||
cwd,
|
||||
message: {
|
||||
@@ -133,6 +140,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
|
||||
afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
sdkMocks.lastQuery = null;
|
||||
if (previousClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
@@ -206,4 +214,67 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
expect(timelineText).toContain(HISTORY_USER_MARKER);
|
||||
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
|
||||
});
|
||||
|
||||
test("listCommands includes rewind command", async () => {
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "history-session",
|
||||
nativeHandle: "history-session",
|
||||
metadata: {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
|
||||
const session = await client.resumeSession(handle, { cwd });
|
||||
try {
|
||||
const commands = await session.listCommands?.();
|
||||
expect(commands?.some((command) => command.name === "rewind")).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("slash /rewind uses latest user message id from persisted history", async () => {
|
||||
const logger = createTestLogger();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "history-session",
|
||||
nativeHandle: "history-session",
|
||||
metadata: {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
|
||||
const session = await client.resumeSession(handle, { cwd });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
try {
|
||||
for await (const event of session.stream("/rewind")) {
|
||||
events.push(event);
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(events.some((event) => event.type === "turn_started")).toBe(true);
|
||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(sdkMocks.lastQuery).toBeTruthy();
|
||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith(
|
||||
"history-user-uuid",
|
||||
{ dryRun: false }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+2486
-2707
File diff suppressed because it is too large
Load Diff
@@ -570,7 +570,19 @@ class FakeAgentSession implements AgentSession {
|
||||
return commands;
|
||||
}
|
||||
|
||||
// claude/opencode: keep stable/deterministic.
|
||||
// Keep deterministic defaults for non-codex providers.
|
||||
if (this.providerName === "claude") {
|
||||
return [
|
||||
{ name: "help", description: "Help", argumentHint: "" },
|
||||
{ name: "context", description: "Context", argumentHint: "" },
|
||||
{
|
||||
name: "rewind",
|
||||
description: "Rewind tracked files to a previous user message",
|
||||
argumentHint: "[user_message_uuid]",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ name: "help", description: "Help", argumentHint: "" },
|
||||
{ name: "context", description: "Context", argumentHint: "" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AgentStreamMessageSchema,
|
||||
@@ -6,172 +6,176 @@ import {
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WSOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
} from './messages.js'
|
||||
|
||||
describe("shared messages stream parsing", () => {
|
||||
it("parses representative fetch_agent_timeline_response payload", () => {
|
||||
describe('shared messages stream parsing', () => {
|
||||
it('parses representative fetch_agent_timeline_response payload', () => {
|
||||
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_response",
|
||||
type: 'fetch_agent_timeline_response',
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
agentId: "agent_live",
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
requestId: 'req-1',
|
||||
agentId: 'agent_live',
|
||||
direction: 'tail',
|
||||
projection: 'projected',
|
||||
epoch: 'epoch-1',
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 2 },
|
||||
startCursor: { epoch: 'epoch-1', seq: 1 },
|
||||
endCursor: { epoch: 'epoch-1', seq: 2 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "hello" },
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
provider: 'codex',
|
||||
item: { type: 'assistant_message', text: 'hello' },
|
||||
timestamp: '2026-02-08T20:10:00.000Z',
|
||||
seqStart: 1,
|
||||
seqEnd: 2,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
||||
collapsed: ["assistant_merge"],
|
||||
collapsed: ['assistant_merge'],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(parsed.payload.entries).toHaveLength(1);
|
||||
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
|
||||
});
|
||||
expect(parsed.payload.entries).toHaveLength(1)
|
||||
expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
|
||||
})
|
||||
|
||||
it("parses representative agent_stream tool_call event", () => {
|
||||
it('parses representative agent_stream tool_call event', () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
type: 'agent_stream',
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
agentId: 'agent_live',
|
||||
timestamp: '2026-02-08T20:10:00.000Z',
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
type: 'timeline',
|
||||
provider: 'claude',
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_live",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
type: 'tool_call',
|
||||
callId: 'call_live',
|
||||
name: 'shell',
|
||||
status: 'running',
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls",
|
||||
type: 'shell',
|
||||
command: 'ls',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(parsed.payload.event.type).toBe("timeline");
|
||||
if (parsed.payload.event.type === "timeline") {
|
||||
expect(parsed.payload.event.item.type).toBe("tool_call");
|
||||
if (parsed.payload.event.item.type === "tool_call") {
|
||||
expect(parsed.payload.event.item.status).toBe("running");
|
||||
expect(parsed.payload.event.type).toBe('timeline')
|
||||
if (parsed.payload.event.type === 'timeline') {
|
||||
expect(parsed.payload.event.item.type).toBe('tool_call')
|
||||
if (parsed.payload.event.item.type === 'tool_call') {
|
||||
expect(parsed.payload.event.item.status).toBe('running')
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||
it('rejects removed initialize_agent_request inbound payload', () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "initialize_agent_request",
|
||||
agentId: "agent-legacy",
|
||||
requestId: "req-legacy-1",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
type: 'initialize_agent_request',
|
||||
agentId: 'agent-legacy',
|
||||
requestId: 'req-legacy-1',
|
||||
})
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects removed initialize_agent_request outbound payload", () => {
|
||||
it('rejects removed initialize_agent_request outbound payload', () => {
|
||||
const parsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "initialize_agent_request",
|
||||
type: 'initialize_agent_request',
|
||||
payload: {
|
||||
requestId: "req-legacy-1",
|
||||
agentId: "agent-legacy",
|
||||
agentStatus: "running",
|
||||
requestId: 'req-legacy-1',
|
||||
agentId: 'agent-legacy',
|
||||
agentStatus: 'running',
|
||||
timelineSize: 12,
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
})
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it("parses directory suggestions request and response payloads", () => {
|
||||
it('parses directory suggestions request and response payloads', () => {
|
||||
const requestParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_request",
|
||||
query: "proj",
|
||||
type: 'directory_suggestions_request',
|
||||
query: 'proj',
|
||||
cwd: '/tmp/project',
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
limit: 20,
|
||||
requestId: "req-dir-1",
|
||||
});
|
||||
expect(requestParsed.success).toBe(true);
|
||||
requestId: 'req-dir-1',
|
||||
})
|
||||
expect(requestParsed.success).toBe(true)
|
||||
|
||||
const responseParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_response",
|
||||
type: 'directory_suggestions_response',
|
||||
payload: {
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
directories: ['/Users/test/projects/paseo'],
|
||||
entries: [{ path: '/Users/test/projects/paseo', kind: 'directory' }],
|
||||
error: null,
|
||||
requestId: "req-dir-1",
|
||||
requestId: 'req-dir-1',
|
||||
},
|
||||
});
|
||||
expect(responseParsed.success).toBe(true);
|
||||
});
|
||||
})
|
||||
expect(responseParsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
|
||||
it('rejects websocket envelope for removed agent_stream_snapshot message type', () => {
|
||||
const fixture = {
|
||||
type: "agent_stream_snapshot",
|
||||
type: 'agent_stream_snapshot',
|
||||
payload: {
|
||||
agentId: "agent-legacy",
|
||||
agentId: 'agent-legacy',
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
const wrapped = WSOutboundMessageSchema.safeParse({
|
||||
type: "session",
|
||||
type: 'session',
|
||||
message: fixture,
|
||||
});
|
||||
expect(wrapped.success).toBe(false);
|
||||
});
|
||||
})
|
||||
expect(wrapped.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects removed legacy git diff request messages", () => {
|
||||
it('rejects removed legacy git diff request messages', () => {
|
||||
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "git_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
type: 'git_diff_request',
|
||||
agentId: 'agent-1',
|
||||
requestId: 'req-1',
|
||||
})
|
||||
expect(gitDiffParsed.success).toBe(false)
|
||||
|
||||
const highlightedParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-2",
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
type: 'highlighted_diff_request',
|
||||
agentId: 'agent-1',
|
||||
requestId: 'req-2',
|
||||
})
|
||||
expect(highlightedParsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects removed legacy git diff response messages", () => {
|
||||
it('rejects removed legacy git diff response messages', () => {
|
||||
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "git_diff_response",
|
||||
type: 'git_diff_response',
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
diff: "",
|
||||
agentId: 'agent-1',
|
||||
diff: '',
|
||||
error: null,
|
||||
requestId: "req-1",
|
||||
requestId: 'req-1',
|
||||
},
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
})
|
||||
expect(gitDiffParsed.success).toBe(false)
|
||||
|
||||
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_response",
|
||||
type: 'highlighted_diff_response',
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
agentId: 'agent-1',
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: "req-2",
|
||||
requestId: 'req-2',
|
||||
},
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(highlightedParsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -471,6 +471,109 @@ describe("checkout git utilities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("returns merged PR status when no open PR exists for the current branch", async () => {
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
const fakeBinDir = join(tempDir, "fake-bin-gh-merged");
|
||||
mkdirSync(fakeBinDir);
|
||||
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
|
||||
symlinkSync(gitPath, join(fakeBinDir, "git"));
|
||||
writeFileSync(
|
||||
join(fakeBinDir, "gh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"if [[ \"${1-}\" == \"--version\" ]]; then",
|
||||
" echo \"gh version 2.0.0\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"args=\"$*\"",
|
||||
"if [[ \"$args\" == *\"state=open\"* ]]; then",
|
||||
" echo \"[]\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
|
||||
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/123\",\"title\":\"Ship feature\",\"state\":\"closed\",\"merged_at\":\"2026-02-18T00:00:00Z\",\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"echo \"unexpected gh args: $args\" >&2",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
|
||||
try {
|
||||
const status = await getPullRequestStatus(repoDir);
|
||||
expect(status.githubFeaturesEnabled).toBe(true);
|
||||
expect(status.status).not.toBeNull();
|
||||
expect(status.status?.url).toContain("/pull/123");
|
||||
expect(status.status?.baseRefName).toBe("main");
|
||||
expect(status.status?.headRefName).toBe("feature");
|
||||
expect(status.status?.isMerged).toBe(true);
|
||||
expect(status.status?.state).toBe("merged");
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat closed-unmerged PRs as shipped status", async () => {
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
const fakeBinDir = join(tempDir, "fake-bin-gh-closed");
|
||||
mkdirSync(fakeBinDir);
|
||||
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
|
||||
symlinkSync(gitPath, join(fakeBinDir, "git"));
|
||||
writeFileSync(
|
||||
join(fakeBinDir, "gh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"if [[ \"${1-}\" == \"--version\" ]]; then",
|
||||
" echo \"gh version 2.0.0\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"args=\"$*\"",
|
||||
"if [[ \"$args\" == *\"state=open\"* ]]; then",
|
||||
" echo \"[]\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
|
||||
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/999\",\"title\":\"Closed without merge\",\"state\":\"closed\",\"merged_at\":null,\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"echo \"unexpected gh args: $args\" >&2",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
|
||||
try {
|
||||
const status = await getPullRequestStatus(repoDir);
|
||||
expect(status.githubFeaturesEnabled).toBe(true);
|
||||
expect(status.status).toBeNull();
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns typed MergeConflictError on merge conflicts", async () => {
|
||||
const conflictFile = join(repoDir, "conflict.txt");
|
||||
writeFileSync(conflictFile, "base\n");
|
||||
|
||||
@@ -1517,6 +1517,7 @@ export interface PullRequestStatus {
|
||||
state: string;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
isMerged: boolean;
|
||||
}
|
||||
|
||||
export interface PullRequestStatusResult {
|
||||
@@ -1592,6 +1593,62 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listPullRequestsForHead(options: {
|
||||
cwd: string;
|
||||
repo: string;
|
||||
owner: string;
|
||||
head: string;
|
||||
state: "open" | "closed";
|
||||
}): Promise<any[]> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${options.repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${options.owner}:${options.head}`,
|
||||
"-F",
|
||||
`state=${options.state}`,
|
||||
],
|
||||
{ cwd: options.cwd }
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function buildPullRequestStatus(current: any, fallbackHead: string): PullRequestStatus | null {
|
||||
if (!current || typeof current !== "object") {
|
||||
return null;
|
||||
}
|
||||
const url = current.html_url ?? current.url;
|
||||
const title = current.title;
|
||||
if (typeof url !== "string" || typeof title !== "string" || !url || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedAt =
|
||||
typeof current.merged_at === "string" && current.merged_at.trim().length > 0
|
||||
? current.merged_at
|
||||
: null;
|
||||
const state =
|
||||
mergedAt !== null
|
||||
? "merged"
|
||||
: typeof current.state === "string" && current.state.trim().length > 0
|
||||
? current.state
|
||||
: "";
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? fallbackHead,
|
||||
isMerged: mergedAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPullRequest(
|
||||
cwd: string,
|
||||
options: CreatePullRequestOptions
|
||||
@@ -1652,22 +1709,15 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
||||
};
|
||||
}
|
||||
const owner = repo.split("/")[0];
|
||||
let stdout: string;
|
||||
let openPulls: any[];
|
||||
try {
|
||||
({ stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
));
|
||||
openPulls = await listPullRequestsForHead({
|
||||
cwd,
|
||||
repo,
|
||||
owner,
|
||||
head,
|
||||
state: "open",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
@@ -1677,22 +1727,50 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||
if (!current) {
|
||||
const openPull = openPulls[0] ?? null;
|
||||
const openStatus = buildPullRequestStatus(openPull, head);
|
||||
if (openStatus) {
|
||||
return {
|
||||
status: openStatus,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
let closedPulls: any[];
|
||||
try {
|
||||
closedPulls = await listPullRequestsForHead({
|
||||
cwd,
|
||||
repo,
|
||||
owner,
|
||||
head,
|
||||
state: "closed",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const mergedClosedPull =
|
||||
closedPulls.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof entry.merged_at === "string" &&
|
||||
entry.merged_at.trim().length > 0
|
||||
) ?? null;
|
||||
const mergedStatus = buildPullRequestStatus(mergedClosedPull, head);
|
||||
if (!mergedStatus) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
},
|
||||
status: mergedStatus,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,165 +1,237 @@
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { searchHomeDirectories } from "./directory-suggestions.js";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
|
||||
|
||||
describe("searchHomeDirectories", () => {
|
||||
let tempRoot: string;
|
||||
let homeDir: string;
|
||||
let outsideDir: string;
|
||||
describe('searchHomeDirectories', () => {
|
||||
let tempRoot: string
|
||||
let homeDir: string
|
||||
let outsideDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
|
||||
homeDir = path.join(tempRoot, "home");
|
||||
outsideDir = path.join(tempRoot, "outside");
|
||||
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
|
||||
homeDir = path.join(tempRoot, 'home')
|
||||
outsideDir = path.join(tempRoot, 'outside')
|
||||
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
mkdirSync(outsideDir, { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true })
|
||||
mkdirSync(outsideDir, { recursive: true })
|
||||
|
||||
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
|
||||
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
||||
mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
|
||||
mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
|
||||
mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
|
||||
mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
|
||||
writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
|
||||
|
||||
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
||||
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
||||
});
|
||||
mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
|
||||
symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("returns an empty list for blank queries", async () => {
|
||||
it('returns an empty list for blank queries', async () => {
|
||||
await expect(
|
||||
searchHomeDirectories({
|
||||
homeDir,
|
||||
query: " ",
|
||||
query: ' ',
|
||||
limit: 10,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it("returns only existing directories", async () => {
|
||||
it('returns only existing directories', async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "proj",
|
||||
query: 'proj',
|
||||
limit: 10,
|
||||
});
|
||||
})
|
||||
|
||||
expect(results).toContain(path.join(homeDir, "projects"));
|
||||
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
|
||||
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
|
||||
});
|
||||
expect(results).toContain(path.join(homeDir, 'projects'))
|
||||
expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
|
||||
expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
|
||||
})
|
||||
|
||||
it("supports home-relative path query syntax", async () => {
|
||||
it('supports home-relative path query syntax', async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "~/projects/pa",
|
||||
query: '~/projects/pa',
|
||||
limit: 10,
|
||||
});
|
||||
})
|
||||
|
||||
expect(results).toEqual([
|
||||
path.join(homeDir, "projects", "paseo"),
|
||||
]);
|
||||
});
|
||||
expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
|
||||
})
|
||||
|
||||
it("prioritizes exact segment matches before segment-prefix matches", async () => {
|
||||
const exactSegmentPath = path.join(
|
||||
homeDir,
|
||||
"something",
|
||||
"faro",
|
||||
"something-else"
|
||||
);
|
||||
const prefixSegmentPath = path.join(
|
||||
homeDir,
|
||||
"something",
|
||||
"somethingelse",
|
||||
"faro-bla"
|
||||
);
|
||||
mkdirSync(exactSegmentPath, { recursive: true });
|
||||
mkdirSync(prefixSegmentPath, { recursive: true });
|
||||
it('prioritizes exact segment matches before segment-prefix matches', async () => {
|
||||
const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
|
||||
const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
|
||||
mkdirSync(exactSegmentPath, { recursive: true })
|
||||
mkdirSync(prefixSegmentPath, { recursive: true })
|
||||
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "faro",
|
||||
query: 'faro',
|
||||
limit: 30,
|
||||
});
|
||||
})
|
||||
|
||||
const exactIndex = results.indexOf(exactSegmentPath);
|
||||
const prefixIndex = results.indexOf(prefixSegmentPath);
|
||||
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(exactIndex).toBeLessThan(prefixIndex);
|
||||
});
|
||||
const exactIndex = results.indexOf(exactSegmentPath)
|
||||
const prefixIndex = results.indexOf(prefixSegmentPath)
|
||||
expect(exactIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(prefixIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(exactIndex).toBeLessThan(prefixIndex)
|
||||
})
|
||||
|
||||
it("prioritizes partial matches that appear earlier in the path", async () => {
|
||||
const earlierPath = path.join(homeDir, "farofoo");
|
||||
const laterPath = path.join(homeDir, "x", "y", "farofoo");
|
||||
mkdirSync(earlierPath, { recursive: true });
|
||||
mkdirSync(laterPath, { recursive: true });
|
||||
it('prioritizes partial matches that appear earlier in the path', async () => {
|
||||
const earlierPath = path.join(homeDir, 'farofoo')
|
||||
const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
|
||||
mkdirSync(earlierPath, { recursive: true })
|
||||
mkdirSync(laterPath, { recursive: true })
|
||||
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "arofo",
|
||||
query: 'arofo',
|
||||
limit: 30,
|
||||
});
|
||||
})
|
||||
|
||||
const earlierIndex = results.indexOf(earlierPath);
|
||||
const laterIndex = results.indexOf(laterPath);
|
||||
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(earlierIndex).toBeLessThan(laterIndex);
|
||||
});
|
||||
const earlierIndex = results.indexOf(earlierPath)
|
||||
const laterIndex = results.indexOf(laterPath)
|
||||
expect(earlierIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(laterIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(earlierIndex).toBeLessThan(laterIndex)
|
||||
})
|
||||
|
||||
it("returns home-root suggestions when query is '~'", async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "~",
|
||||
query: '~',
|
||||
limit: 20,
|
||||
});
|
||||
})
|
||||
|
||||
expect(results).toContain(path.join(homeDir, "projects"));
|
||||
expect(results).toContain(path.join(homeDir, "documents"));
|
||||
expect(results).not.toContain(path.join(homeDir, ".hidden"));
|
||||
});
|
||||
expect(results).toContain(path.join(homeDir, 'projects'))
|
||||
expect(results).toContain(path.join(homeDir, 'documents'))
|
||||
expect(results).not.toContain(path.join(homeDir, '.hidden'))
|
||||
})
|
||||
|
||||
it("does not return hidden directories during tree search", async () => {
|
||||
it('does not return hidden directories during tree search', async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "cache",
|
||||
query: 'cache',
|
||||
limit: 20,
|
||||
});
|
||||
})
|
||||
|
||||
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
|
||||
});
|
||||
expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
|
||||
})
|
||||
|
||||
it("does not return paths that escape home through symlinks", async () => {
|
||||
it('does not return paths that escape home through symlinks', async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "outside",
|
||||
query: 'outside',
|
||||
limit: 20,
|
||||
});
|
||||
})
|
||||
|
||||
expect(results).not.toContain(path.join(homeDir, "outside-link"));
|
||||
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
|
||||
});
|
||||
expect(results).not.toContain(path.join(homeDir, 'outside-link'))
|
||||
expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
|
||||
})
|
||||
|
||||
it("respects the result limit", async () => {
|
||||
it('respects the result limit', async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "p",
|
||||
query: 'p',
|
||||
limit: 1,
|
||||
});
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
expect(results).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchWorkspaceEntries', () => {
|
||||
let tempRoot: string
|
||||
let workspaceDir: string
|
||||
let outsideDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
|
||||
workspaceDir = path.join(tempRoot, 'workspace')
|
||||
outsideDir = path.join(tempRoot, 'outside')
|
||||
|
||||
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
|
||||
recursive: true,
|
||||
})
|
||||
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
|
||||
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
|
||||
|
||||
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
|
||||
writeFileSync(
|
||||
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
|
||||
'export const ChatInput = null;\n'
|
||||
)
|
||||
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
|
||||
|
||||
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns relative file and directory suggestions for workspace queries', async () => {
|
||||
const results = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: 'chat',
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
})
|
||||
|
||||
expect(results).toContainEqual({
|
||||
path: 'src/components/chat-input.tsx',
|
||||
kind: 'file',
|
||||
})
|
||||
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
|
||||
})
|
||||
|
||||
it('filters entries by kind', async () => {
|
||||
const dirsOnly = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: 'src',
|
||||
limit: 20,
|
||||
includeFiles: false,
|
||||
includeDirectories: true,
|
||||
})
|
||||
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
|
||||
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
|
||||
|
||||
const filesOnly = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: 'readme',
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
})
|
||||
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
|
||||
})
|
||||
|
||||
it('supports path-style queries and does not escape cwd through symlinks', async () => {
|
||||
const pathResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: 'src/co',
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
})
|
||||
expect(pathResults).toContainEqual({
|
||||
path: 'src/components',
|
||||
kind: 'directory',
|
||||
})
|
||||
|
||||
const escapedResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: 'escaped',
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
})
|
||||
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,106 @@ const dependencySections = [
|
||||
|
||||
const touched = []
|
||||
|
||||
function syncDesktopTauriConfigVersion(version) {
|
||||
const tauriConfigPath = path.join(
|
||||
rootDir,
|
||||
'packages',
|
||||
'desktop',
|
||||
'src-tauri',
|
||||
'tauri.conf.json'
|
||||
)
|
||||
|
||||
if (!existsSync(tauriConfigPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, 'utf8'))
|
||||
if (tauriConfig.version === version) {
|
||||
return
|
||||
}
|
||||
|
||||
tauriConfig.version = version
|
||||
writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`)
|
||||
touched.push(path.relative(rootDir, tauriConfigPath))
|
||||
}
|
||||
|
||||
function syncDesktopCargoPackageVersion(version) {
|
||||
const cargoTomlPath = path.join(
|
||||
rootDir,
|
||||
'packages',
|
||||
'desktop',
|
||||
'src-tauri',
|
||||
'Cargo.toml'
|
||||
)
|
||||
|
||||
if (!existsSync(cargoTomlPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const cargoLines = readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/)
|
||||
let inPackageSection = false
|
||||
let updated = false
|
||||
|
||||
const nextLines = cargoLines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) {
|
||||
inPackageSection = true
|
||||
return line
|
||||
}
|
||||
|
||||
if (inPackageSection && /^\[/.test(line)) {
|
||||
inPackageSection = false
|
||||
return line
|
||||
}
|
||||
|
||||
if (inPackageSection && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
const expectedLine = `version = "${version}"`
|
||||
if (line === expectedLine) {
|
||||
return line
|
||||
}
|
||||
updated = true
|
||||
return expectedLine
|
||||
}
|
||||
|
||||
return line
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
|
||||
writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`)
|
||||
touched.push(path.relative(rootDir, cargoTomlPath))
|
||||
}
|
||||
|
||||
function syncDesktopInfoPlistVersion(version) {
|
||||
const infoPlistPath = path.join(
|
||||
rootDir,
|
||||
'packages',
|
||||
'desktop',
|
||||
'src-tauri',
|
||||
'Info.plist'
|
||||
)
|
||||
|
||||
if (!existsSync(infoPlistPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = readFileSync(infoPlistPath, 'utf8')
|
||||
|
||||
const shortVersionPattern = /(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/
|
||||
const bundleVersionPattern = /(<key>CFBundleVersion<\/key>\s*<string>)([^<]+)(<\/string>)/
|
||||
|
||||
let next = current.replace(shortVersionPattern, `$1${version}$3`)
|
||||
next = next.replace(bundleVersionPattern, `$1${version}$3`)
|
||||
|
||||
if (next === current) {
|
||||
return
|
||||
}
|
||||
|
||||
writeFileSync(infoPlistPath, next)
|
||||
touched.push(path.relative(rootDir, infoPlistPath))
|
||||
}
|
||||
|
||||
for (const workspacePath of workspacePaths) {
|
||||
const packagePath = path.join(rootDir, workspacePath, 'package.json')
|
||||
if (!existsSync(packagePath)) {
|
||||
@@ -63,6 +163,10 @@ for (const workspacePath of workspacePaths) {
|
||||
}
|
||||
}
|
||||
|
||||
syncDesktopTauriConfigVersion(rootVersion)
|
||||
syncDesktopCargoPackageVersion(rootVersion)
|
||||
syncDesktopInfoPlistVersion(rootVersion)
|
||||
|
||||
if (touched.length === 0) {
|
||||
console.log(`Workspace versions and internal deps already synced to ${rootVersion}`)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user