Merge remote-tracking branch 'origin/main' into pr59-sync

# Conflicts:
#	packages/app/src/components/git-diff-pane.tsx
This commit is contained in:
Mohamed Boudra
2026-02-18 18:21:48 +07:00
54 changed files with 10172 additions and 7937 deletions
+11
View File
@@ -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
+62 -25
View File
@@ -54,7 +54,7 @@ import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
// =============================================================================
@@ -479,6 +479,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
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 });
@@ -816,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(() => {
@@ -841,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 }) => {
@@ -907,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 =
@@ -995,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)
@@ -1089,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,
});
@@ -1121,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
@@ -1133,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";
@@ -1153,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,
File diff suppressed because it is too large Load Diff
+83 -67
View File
@@ -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
}
+271 -274
View File
@@ -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',
},
}));
}))
+4 -4
View File
@@ -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;
}
+227 -62
View File
@@ -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) {
+3 -2
View File
@@ -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;
}
+4 -7
View File
@@ -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
+295 -111
View File
@@ -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,
+3 -24
View File
@@ -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}`
}
+5 -15
View File
@@ -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",
});
});
});
+105 -1
View File
@@ -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?"
);
}
+9 -11
View File
@@ -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;
}
+92
View File
@@ -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;
}
}