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
+1 -1
View File
@@ -30,7 +30,7 @@
"ios": "npm run ios --workspace=@getpaseo/app", "ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run build --workspace=@getpaseo/desktop", "build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js", "cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs", "version:sync-internal": "node scripts/sync-workspace-versions.mjs",
+1 -1
View File
@@ -394,7 +394,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible(); await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
await page.getByTestId('changes-menu-archive-worktree').click(); await page.getByTestId('changes-menu-archive-worktree').click();
// Archiving a worktree deletes agents and redirects to home // Archiving a worktree deletes agents and redirects to home
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 }); await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path); await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page); await ensureHostSelected(page);
// Repo inspection is async; wait until git options are interactive again. // Repo inspection is async; wait until git options are interactive again.
+11
View File
@@ -42,12 +42,14 @@ import { queryClient } from "@/query/query-client";
import { import {
WEB_NOTIFICATION_CLICK_EVENT, WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail, type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications"; } from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildNotificationRoute } from "@/utils/notification-routing";
import { import {
buildHostAgentDraftRoute, buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
polyfillCrypto(); polyfillCrypto();
@@ -57,6 +59,15 @@ function PushNotificationRouter() {
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") { 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 target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => { const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>; 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, WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics, useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar"; } 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"; import { openExternalUrl } from "@/utils/open-external-url";
// ============================================================================= // =============================================================================
@@ -479,6 +479,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const router = useRouter(); const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, cwd }); useCheckoutStatusQuery({ serverId, cwd });
@@ -816,10 +817,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
} }
void persistShipDefault("merge"); void persistShipDefault("merge");
setActionError(null); setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => { void runMergeBranch({ serverId, cwd, baseRef })
const message = err instanceof Error ? err.message : "Failed to merge"; .then(() => {
setActionError(message); setPostShipArchiveSuggested(true);
}); })
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
const handleMergeFromBase = useCallback(() => { const handleMergeFromBase = useCallback(() => {
@@ -841,15 +846,16 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return; return;
} }
setActionError(null); setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath }) void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => { .then(() => {
router.replace(buildHostAgentDraftRoute(serverId) as any); router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
}) })
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree"; const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message); setActionError(message);
}); });
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]); }, [runArchiveWorktree, router, serverId, cwd, status]);
const renderFlatItem = useCallback( const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => { ({ item }: { item: DiffFlatItem }) => {
@@ -907,12 +913,27 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
? undefined ? undefined
: `${branchLabel} -> ${baseRefLabel}`; : `${branchLabel} -> ${baseRefLabel}`;
}, [baseRefLabel, branchLabel]); }, [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 commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef; actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
const mergeFromBaseDisabled = const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef; actionsDisabled ||
mergeFromBaseStatus === "pending" ||
hasUncommittedChanges ||
!baseRef ||
(isOnBaseBranch && !hasRemote);
const pushDisabled = const pushDisabled =
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false); actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
const archiveDisabled = const archiveDisabled =
@@ -995,11 +1016,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
); );
} }
const hasPullRequest = Boolean(prStatus?.url); useEffect(() => {
const hasRemote = gitStatus?.hasRemote ?? false; setPostShipArchiveSuggested(false);
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; }, [cwd]);
const currentBranch = gitStatus?.currentBranch;
const isOnBaseBranch = currentBranch === baseRefLabel;
// ========================================================================== // ==========================================================================
// Git Actions (Data-Oriented) // Git Actions (Data-Oriented)
@@ -1089,16 +1108,21 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}); });
} }
// Update from base - only when not on base branch // Update/sync from base
if (!isOnBaseBranch) { if (!isOnBaseBranch || hasRemote) {
allActions.set("merge-from-base", { allActions.set("merge-from-base", {
id: "merge-from-base", id: "merge-from-base",
label: `Update from ${baseRefLabel}`, label: isOnBaseBranch ? `Sync ${baseRefLabel} from origin` : `Update from ${baseRefLabel}`,
pendingLabel: "Updating...", pendingLabel: "Updating...",
successLabel: "Updated", successLabel: "Updated",
disabled: mergeFromBaseDisabled, disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus, 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} />, icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase, handler: handleMergeFromBase,
}); });
@@ -1121,8 +1145,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
// Select primary action (priority rules) // Select primary action (priority rules)
let primaryActionId: GitActionId | null = null; 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 // Rule 1: Uncommitted changes → Commit
if (hasUncommittedChanges) { else if (hasUncommittedChanges) {
primaryActionId = "commit"; primaryActionId = "commit";
} }
// Rule 2: Ahead of origin → Push // Rule 2: Ahead of origin → Push
@@ -1133,7 +1161,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
else if (hasPullRequest) { else if (hasPullRequest) {
primaryActionId = "view-pr"; 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) { else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr"; const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch"; 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; const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
// Secondary actions: ship-related + merge from base + push (excluding primary) // 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 const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id)) .filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!); .map(id => allActions.get(id)!);
// Menu actions: archive worktree only // Menu actions: none for now (all actionable items are in primary/secondary)
const menu = allActions.has("archive-worktree") const menu: GitAction[] = [];
? [allActions.get("archive-worktree")!]
: [];
return { primary, secondary, menu }; return { primary, secondary, menu };
}, [ }, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, 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 { useCallback, useEffect, useRef } from 'react'
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Theme } from "@/styles/theme"; import { File, Folder } from 'lucide-react-native'
import { getAutocompleteScrollOffset } from "./autocomplete-utils"; import { Theme } from '@/styles/theme'
import { getAutocompleteScrollOffset } from './autocomplete-utils'
export interface AutocompleteOption { export interface AutocompleteOption {
id: string; id: string
label: string; label: string
detail?: string; detail?: string
description?: string; description?: string
kind?: 'command' | 'file' | 'directory'
} }
interface AutocompleteProps { interface AutocompleteProps {
options: readonly AutocompleteOption[]; options: readonly AutocompleteOption[]
selectedIndex: number; selectedIndex: number
onSelect: (option: AutocompleteOption) => void; onSelect: (option: AutocompleteOption) => void
isLoading?: boolean; isLoading?: boolean
errorMessage?: string; errorMessage?: string
loadingText?: string; loadingText?: string
emptyText?: string; emptyText?: string
maxHeight?: number; maxHeight?: number
} }
export function Autocomplete({ export function Autocomplete({
@@ -28,23 +30,24 @@ export function Autocomplete({
onSelect, onSelect,
isLoading = false, isLoading = false,
errorMessage, errorMessage,
loadingText = "Loading...", loadingText = 'Loading...',
emptyText = "No results found", emptyText = 'No results found',
maxHeight = 220, maxHeight = 220,
}: AutocompleteProps) { }: AutocompleteProps) {
const scrollRef = useRef<ScrollView>(null); const { theme } = useUnistyles()
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map()); const scrollRef = useRef<ScrollView>(null)
const viewportHeightRef = useRef(0); const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map())
const scrollOffsetRef = useRef(0); const viewportHeightRef = useRef(0)
const scrollOffsetRef = useRef(0)
const ensureActiveItemVisible = useCallback(() => { const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) { if (selectedIndex < 0) {
return; return
} }
const layout = rowLayoutsRef.current.get(selectedIndex); const layout = rowLayoutsRef.current.get(selectedIndex)
if (!layout) { if (!layout) {
return; return
} }
const nextOffset = getAutocompleteScrollOffset({ const nextOffset = getAutocompleteScrollOffset({
@@ -52,60 +55,60 @@ export function Autocomplete({
viewportHeight: viewportHeightRef.current, viewportHeight: viewportHeightRef.current,
itemTop: layout.top, itemTop: layout.top,
itemHeight: layout.height, itemHeight: layout.height,
}); })
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) { if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return; return
} }
scrollOffsetRef.current = nextOffset; scrollOffsetRef.current = nextOffset
scrollRef.current?.scrollTo({ y: nextOffset, animated: false }); scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}, [selectedIndex]); }, [selectedIndex])
const pinToBottom = useCallback(() => { const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false }); scrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false }); scrollRef.current?.scrollToEnd({ animated: false })
}); })
}, []); }, [])
useEffect(() => { useEffect(() => {
rowLayoutsRef.current.clear(); rowLayoutsRef.current.clear()
scrollOffsetRef.current = 0; scrollOffsetRef.current = 0
}, [options]); }, [options])
useEffect(() => { useEffect(() => {
if (options.length === 0) { if (options.length === 0) {
return; return
} }
pinToBottom(); pinToBottom()
}, [options, pinToBottom]); }, [options, pinToBottom])
useEffect(() => { useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible); const raf = requestAnimationFrame(ensureActiveItemVisible)
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf)
}; }
}, [ensureActiveItemVisible, options.length]); }, [ensureActiveItemVisible, options.length])
const handleScrollViewLayout = useCallback( const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => { (event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height; viewportHeightRef.current = event.nativeEvent.layout.height
ensureActiveItemVisible(); ensureActiveItemVisible()
}, },
[ensureActiveItemVisible] [ensureActiveItemVisible]
); )
const handleRowLayout = useCallback( const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => { (index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, { rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y, top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height, height: event.nativeEvent.layout.height,
}); })
ensureActiveItemVisible(); ensureActiveItemVisible()
}, },
[ensureActiveItemVisible] [ensureActiveItemVisible]
); )
if (isLoading) { if (isLoading) {
return ( return (
@@ -114,7 +117,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{loadingText}</Text> <Text style={styles.emptyText}>{loadingText}</Text>
</View> </View>
</View> </View>
); )
} }
if (errorMessage) { if (errorMessage) {
@@ -124,7 +127,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>Error: {errorMessage}</Text> <Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View> </View>
</View> </View>
); )
} }
if (options.length === 0) { if (options.length === 0) {
@@ -134,7 +137,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{emptyText}</Text> <Text style={styles.emptyText}>{emptyText}</Text>
</View> </View>
</View> </View>
); )
} }
return ( return (
@@ -144,7 +147,7 @@ export function Autocomplete({
onLayout={handleScrollViewLayout} onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom} onContentSizeChange={pinToBottom}
onScroll={(event) => { onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y; scrollOffsetRef.current = event.nativeEvent.contentOffset.y
}} }}
scrollEventThrottle={16} scrollEventThrottle={16}
style={styles.scrollView} style={styles.scrollView}
@@ -152,7 +155,7 @@ export function Autocomplete({
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
> >
{options.map((option, index) => { {options.map((option, index) => {
const isSelected = index === selectedIndex; const isSelected = index === selectedIndex
return ( return (
<Pressable <Pressable
key={option.id} key={option.id}
@@ -163,12 +166,19 @@ export function Autocomplete({
(hovered || pressed || isSelected) && styles.itemActive, (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.itemMain}>
<View style={styles.itemHeader}> <View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{option.label}</Text> <Text style={styles.itemLabel}>{option.label}</Text>
{option.detail ? ( {option.detail ? <Text style={styles.itemDetail}>{option.detail}</Text> : null}
<Text style={styles.itemDetail}>{option.detail}</Text>
) : null}
</View> </View>
{option.description ? ( {option.description ? (
<Text style={styles.itemDescription} numberOfLines={1}> <Text style={styles.itemDescription} numberOfLines={1}>
@@ -177,11 +187,11 @@ export function Autocomplete({
) : null} ) : null}
</View> </View>
</Pressable> </Pressable>
); )
})} })}
</ScrollView> </ScrollView>
</View> </View>
); )
} }
const styles = StyleSheet.create(((theme: Theme) => ({ const styles = StyleSheet.create(((theme: Theme) => ({
@@ -190,7 +200,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
overflow: "hidden", overflow: 'hidden',
}, },
scrollView: { scrollView: {
flexGrow: 0, flexGrow: 0,
@@ -200,12 +210,18 @@ const styles = StyleSheet.create(((theme: Theme) => ({
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
item: { item: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
minHeight: 36, minHeight: 36,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
}, },
itemLeading: {
width: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: theme.spacing[1],
},
itemActive: { itemActive: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
@@ -214,8 +230,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
minWidth: 0, minWidth: 0,
}, },
itemHeader: { itemHeader: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
itemLabel: { itemLabel: {
@@ -240,4 +256,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, 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 { export interface ComboboxOptionModel {
id: string; id: string
label: string; label: string
description?: string; description?: string
kind?: "directory"; kind?: ComboboxOptionKind
} }
export interface BuildVisibleComboboxOptionsInput { export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[]; options: ComboboxOptionModel[]
searchQuery: string; searchQuery: string
searchable: boolean; searchable: boolean
allowCustomValue: boolean; allowCustomValue: boolean
customValuePrefix: string; customValuePrefix: string
customValueDescription?: string; customValueDescription?: string
customValueKind?: "directory"; customValueKind?: ComboboxOptionKind
} }
export function shouldShowCustomComboboxOption(input: { export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[]; options: ComboboxOptionModel[]
searchQuery: string; searchQuery: string
searchable: boolean; searchable: boolean
allowCustomValue: boolean; allowCustomValue: boolean
}): boolean { }): boolean {
const sanitizedSearchValue = input.searchQuery.trim(); const sanitizedSearchValue = input.searchQuery.trim()
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) { if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false; return false
} }
return !input.options.some( return !input.options.some(
(opt) => (opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
); )
} }
export function buildVisibleComboboxOptions( export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput input: BuildVisibleComboboxOptionsInput
): ComboboxOptionModel[] { ): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
const filteredOptions = normalizedSearch const filteredOptions = normalizedSearch
? input.options.filter( ? input.options.filter(
(opt) => (opt) =>
@@ -44,52 +46,50 @@ export function buildVisibleComboboxOptions(
opt.id.toLowerCase().includes(normalizedSearch) || opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.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({ const showCustomOption = shouldShowCustomComboboxOption({
options: input.options, options: input.options,
searchQuery: input.searchQuery, searchQuery: input.searchQuery,
searchable: input.searchable, searchable: input.searchable,
allowCustomValue: input.allowCustomValue, allowCustomValue: input.allowCustomValue,
}); })
const visibleOptions: ComboboxOptionModel[] = []; const visibleOptions: ComboboxOptionModel[] = []
if (showCustomOption) { if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim(); const trimmedPrefix = input.customValuePrefix.trim()
const customLabel = const customLabel =
trimmedPrefix.length > 0 trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
visibleOptions.push({ visibleOptions.push({
id: sanitizedSearchValue, id: sanitizedSearchValue,
label: customLabel, label: customLabel,
description: input.customValueDescription, description: input.customValueDescription,
kind: input.customValueKind, kind: input.customValueKind,
}); })
} }
visibleOptions.push(...filteredOptions); visibleOptions.push(...filteredOptions)
return visibleOptions; return visibleOptions
} }
export function orderVisibleComboboxOptions( export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[], visibleOptions: ComboboxOptionModel[],
optionsPosition: "below-search" | "above-search" optionsPosition: 'below-search' | 'above-search'
): ComboboxOptionModel[] { ): ComboboxOptionModel[] {
if (optionsPosition !== "above-search") { if (optionsPosition !== 'above-search') {
return visibleOptions; return visibleOptions
} }
return [...visibleOptions].reverse(); return [...visibleOptions].reverse()
} }
export function getComboboxFallbackIndex( export function getComboboxFallbackIndex(
itemCount: number, itemCount: number,
optionsPosition: "below-search" | "above-search" optionsPosition: 'below-search' | 'above-search'
): number { ): number {
if (itemCount <= 0) { 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from 'react'
import { import {
View, View,
Text, Text,
@@ -10,87 +10,88 @@ import {
Platform, Platform,
StatusBar, StatusBar,
useWindowDimensions, useWindowDimensions,
} from "react-native"; } from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { import {
BottomSheetModal, BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from '@gorhom/bottom-sheet'
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { Check, Folder, Search } from "lucide-react-native"; 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 {
import { getNextActiveIndex } from "./combobox-keyboard"; flip,
offset as floatingOffset,
shift,
size as floatingSize,
useFloating,
} from '@floating-ui/react-native'
import { getNextActiveIndex } from './combobox-keyboard'
import { import {
buildVisibleComboboxOptions, buildVisibleComboboxOptions,
getComboboxFallbackIndex, getComboboxFallbackIndex,
orderVisibleComboboxOptions, orderVisibleComboboxOptions,
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from './combobox-options'
import type { ComboboxOptionModel } 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 { export interface ComboboxProps {
options: ComboboxOption[]; options: ComboboxOption[]
value: string; value: string
onSelect: (id: string) => void; onSelect: (id: string) => void
onSearchQueryChange?: (query: string) => void; onSearchQueryChange?: (query: string) => void
searchable?: boolean; searchable?: boolean
placeholder?: string; placeholder?: string
searchPlaceholder?: string; searchPlaceholder?: string
emptyText?: string; emptyText?: string
allowCustomValue?: boolean; allowCustomValue?: boolean
customValuePrefix?: string; customValuePrefix?: string
customValueDescription?: string; customValueDescription?: string
customValueKind?: "directory"; customValueKind?: 'directory' | 'file'
optionsPosition?: "below-search" | "above-search"; optionsPosition?: 'below-search' | 'above-search'
title?: string; title?: string
open?: boolean; open?: boolean
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: 'top-start' | 'bottom-start'
/** /**
* Prevents an initial frame at 0,0 by hiding desktop content until floating * Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation * coordinates resolve. This intentionally disables fade enter/exit animation
* for that combobox instance to avoid animation overriding hidden opacity. * for that combobox instance to avoid animation overriding hidden opacity.
*/ */
desktopPreventInitialFlash?: boolean; desktopPreventInitialFlash?: boolean
anchorRef: React.RefObject<View | null>; anchorRef: React.RefObject<View | null>
children?: ReactNode; children?: ReactNode
} }
function toNumericStyleValue(value: unknown): number | null { function toNumericStyleValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return value; return value
} }
if (typeof value === "string") { if (typeof value === 'string') {
const parsed = Number.parseFloat(value); const parsed = Number.parseFloat(value)
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) {
return parsed; return parsed
} }
} }
return null; return null
} }
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return ( return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
} }
interface SearchInputProps { interface SearchInputProps {
placeholder: string; placeholder: string
value: string; value: string
onChangeText: (text: string) => void; onChangeText: (text: string) => void
onSubmitEditing?: () => void; onSubmitEditing?: () => void
autoFocus?: boolean; autoFocus?: boolean
} }
function SearchInput({ function SearchInput({
@@ -100,18 +101,18 @@ function SearchInput({
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles()
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null)
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus()
}, 50); }, 50)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [autoFocus]); }, [autoFocus])
return ( return (
<View style={styles.searchInputContainer}> <View style={styles.searchInputContainer}>
@@ -119,7 +120,7 @@ function SearchInput({
<InputComponent <InputComponent
ref={inputRef as any} ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]} style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={value} value={value}
@@ -129,17 +130,17 @@ function SearchInput({
onSubmitEditing={onSubmitEditing} onSubmitEditing={onSubmitEditing}
/> />
</View> </View>
); )
} }
export interface ComboboxItemProps { export interface ComboboxItemProps {
label: string; label: string
description?: string; description?: string
kind?: "directory"; kind?: 'directory' | 'file'
selected?: boolean; selected?: boolean
active?: boolean; active?: boolean
onPress: () => void; onPress: () => void
testID?: string; testID?: string
} }
export function ComboboxItem({ export function ComboboxItem({
@@ -151,7 +152,7 @@ export function ComboboxItem({
onPress, onPress,
testID, testID,
}: ComboboxItemProps): ReactElement { }: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles()
return ( return (
<Pressable <Pressable
testID={testID} testID={testID}
@@ -163,15 +164,23 @@ export function ComboboxItem({
active && styles.comboboxItemActive, active && styles.comboboxItemActive,
]} ]}
> >
{kind === "directory" ? ( {kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}> <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> </View>
) : null} ) : null}
<View style={styles.comboboxItemContent}> <View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text> <Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
</Text>
{description ? ( {description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text> <Text numberOfLines={2} style={styles.comboboxItemDescription}>
{description}
</Text>
) : null} ) : null}
</View> </View>
{selected ? ( {selected ? (
@@ -180,11 +189,15 @@ export function ComboboxItem({
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
); )
} }
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { 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({ export function Combobox({
@@ -193,202 +206,196 @@ export function Combobox({
onSelect, onSelect,
onSearchQueryChange, onSearchQueryChange,
searchable = true, searchable = true,
placeholder = "Search...", placeholder = 'Search...',
searchPlaceholder, searchPlaceholder,
emptyText = "No options match your search.", emptyText = 'No options match your search.',
allowCustomValue = false, allowCustomValue = false,
customValuePrefix = "Use", customValuePrefix = 'Use',
customValueDescription, customValueDescription,
customValueKind, customValueKind,
optionsPosition = "below-search", optionsPosition = 'below-search',
title = "Select", title = 'Select',
open, open,
onOpenChange, onOpenChange,
desktopPlacement = "top-start", desktopPlacement = 'top-start',
desktopPreventInitialFlash = true, desktopPreventInitialFlash = true,
anchorRef, anchorRef,
children, children,
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const isMobile = const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
const effectiveOptionsPosition =
isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; !isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null)
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ['60%', '90%'], [])
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
const [referenceWidth, setReferenceWidth] = useState<number | null>(null); null
const [referenceTop, setReferenceTop] = useState<number | null>(null); )
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState(""); const [referenceTop, setReferenceTop] = useState<number | null>(null)
const [activeIndex, setActiveIndex] = useState<number>(-1); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
const desktopOptionsScrollRef = useRef<ScrollView>(null); const [searchQuery, setSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState<number>(-1)
const desktopOptionsScrollRef = useRef<ScrollView>(null)
const isControlled = typeof open === "boolean"; const isControlled = typeof open === 'boolean'
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false)
const isOpen = isControlled ? open : internalOpen; const isOpen = isControlled ? open : internalOpen
const setOpen = useCallback( const setOpen = useCallback(
(nextOpen: boolean) => { (nextOpen: boolean) => {
if (!isControlled) { if (!isControlled) {
setInternalOpen(nextOpen); setInternalOpen(nextOpen)
} }
onOpenChange?.(nextOpen); onOpenChange?.(nextOpen)
}, },
[isControlled, onOpenChange] [isControlled, onOpenChange]
); )
const setSearchQueryWithCallback = useCallback( const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => { (nextQuery: string) => {
setSearchQuery(nextQuery); setSearchQuery(nextQuery)
onSearchQueryChange?.(nextQuery); onSearchQueryChange?.(nextQuery)
}, },
[onSearchQueryChange] [onSearchQueryChange]
); )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpen(false); setOpen(false)
setSearchQueryWithCallback(""); setSearchQueryWithCallback('')
}, [setOpen, setSearchQueryWithCallback]); }, [setOpen, setSearchQueryWithCallback])
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setSearchQueryWithCallback(""); setSearchQueryWithCallback('')
} }
}, [isOpen, setSearchQueryWithCallback]); }, [isOpen, setSearchQueryWithCallback])
const collisionPadding = useMemo(() => { const collisionPadding = useMemo(() => {
const basePadding = 16; const basePadding = 16
if (Platform.OS !== "android") return basePadding; if (Platform.OS !== 'android') return basePadding
const statusBarHeight = StatusBar.currentHeight ?? 0; const statusBarHeight = StatusBar.currentHeight ?? 0
return Math.max(basePadding, statusBarHeight + basePadding); return Math.max(basePadding, statusBarHeight + basePadding)
}, []); }, [])
const middleware = useMemo( const middleware = useMemo(
() => [ () => [
floatingOffset(Platform.OS === "web" ? 0 : 4), floatingOffset(Platform.OS === 'web' ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), ...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({ floatingSize({
padding: collisionPadding, padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) { apply({ availableWidth, availableHeight, rects }) {
setAvailableSize((prev) => { setAvailableSize((prev) => {
const next = { width: availableWidth, height: availableHeight }; const next = { width: availableWidth, height: availableHeight }
if (!prev) return next; if (!prev) return next
if (prev.width === next.width && prev.height === next.height) return prev; if (prev.width === next.width && prev.height === next.height) return prev
return next; return next
}); })
setReferenceWidth((prev) => { setReferenceWidth((prev) => {
const next = rects.reference.width; const next = rects.reference.width
if (prev === next) return prev; if (prev === next) return prev
return next; return next
}); })
}, },
}), }),
], ],
[collisionPadding, isDesktopAboveSearch] [collisionPadding, isDesktopAboveSearch]
); )
const { refs, floatingStyles, update } = useFloating({ const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
middleware, middleware,
sameScrollView: false, sameScrollView: false,
elements: { elements: {
reference: anchorRef.current ?? undefined, reference: anchorRef.current ?? undefined,
}, },
}); })
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setAvailableSize(null); setAvailableSize(null)
setReferenceWidth(null); setReferenceWidth(null)
return; return
} }
const raf = requestAnimationFrame(() => void update()); const raf = requestAnimationFrame(() => void update())
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf)
}, [desktopPlacement, isMobile, update, isOpen]); }, [desktopPlacement, isMobile, update, isOpen])
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setReferenceAtOrigin(false); setReferenceAtOrigin(false)
setReferenceTop(null); setReferenceTop(null)
return; return
} }
const referenceEl = anchorRef.current; const referenceEl = anchorRef.current
if (!referenceEl) { if (!referenceEl) {
setReferenceAtOrigin(false); setReferenceAtOrigin(false)
setReferenceTop(null); setReferenceTop(null)
return; return
} }
const measure = () => { const measure = () => {
referenceEl.measureInWindow((x, y) => { referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
setReferenceTop((prev) => (prev === y ? prev : y)); setReferenceTop((prev) => (prev === y ? prev : y))
}); })
}; }
measure(); measure()
const raf = requestAnimationFrame(measure); const raf = requestAnimationFrame(measure)
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf)
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]); }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
const floatingTop = toNumericStyleValue(floatingStyles.top); const floatingTop = toNumericStyleValue(floatingStyles.top)
const floatingLeft = toNumericStyleValue(floatingStyles.left); const floatingLeft = toNumericStyleValue(floatingStyles.left)
const desktopAboveSearchBottom = const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding) ? Math.max(windowHeight - referenceTop, collisionPadding)
: null; : null
const hasResolvedDesktopPosition = const hasResolvedDesktopPosition =
referenceWidth !== null && referenceWidth !== null &&
floatingLeft !== null && floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
const shouldHideDesktopContent = const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
desktopPreventInitialFlash && !hasResolvedDesktopPosition; const shouldUseDesktopFade = !desktopPreventInitialFlash
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch const desktopPositionStyle = isDesktopAboveSearch
? { ? {
left: floatingLeft ?? 0, left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0, bottom: desktopAboveSearchBottom ?? 0,
} }
: floatingStyles; : floatingStyles
useEffect(() => { useEffect(() => {
if (!isMobile) return; if (!isMobile) return
if (isOpen) { if (isOpen) {
bottomSheetRef.current?.present(); bottomSheetRef.current?.present()
} else { } else {
bottomSheetRef.current?.dismiss(); bottomSheetRef.current?.dismiss()
} }
}, [isOpen, isMobile]); }, [isOpen, isMobile])
const handleSheetChange = useCallback( const handleSheetChange = useCallback(
(index: number) => { (index: number) => {
if (index === -1) { if (index === -1) {
handleClose(); handleClose()
} }
}, },
[handleClose] [handleClose]
); )
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
), ),
[] []
); )
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
const sanitizedSearchValue = searchQuery.trim(); const sanitizedSearchValue = searchQuery.trim()
const showCustomOption = useMemo( const showCustomOption = useMemo(
() => () =>
shouldShowCustomComboboxOption({ shouldShowCustomComboboxOption({
@@ -398,7 +405,7 @@ export function Combobox({
allowCustomValue, allowCustomValue,
}), }),
[allowCustomValue, options, searchQuery, searchable] [allowCustomValue, options, searchQuery, searchable]
); )
const visibleOptions = useMemo( const visibleOptions = useMemo(
() => () =>
@@ -420,139 +427,132 @@ export function Combobox({
searchQuery, searchQuery,
searchable, searchable,
] ]
); )
const orderedVisibleOptions = useMemo( const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition), () => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions] [effectiveOptionsPosition, visibleOptions]
); )
const pinDesktopOptionsToBottom = useCallback(() => { const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== "above-search") { if (isMobile || effectiveOptionsPosition !== 'above-search') {
return; return
} }
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => { requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
}); })
}, [effectiveOptionsPosition, isMobile]); }, [effectiveOptionsPosition, isMobile])
const handleDesktopOptionsContentSizeChange = useCallback(() => { const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) { if (!isOpen) {
return; return
} }
pinDesktopOptionsToBottom(); pinDesktopOptionsToBottom()
}, [isOpen, pinDesktopOptionsToBottom]); }, [isOpen, pinDesktopOptionsToBottom])
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return
} }
pinDesktopOptionsToBottom(); pinDesktopOptionsToBottom()
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
return; return
} }
void update(); void update()
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]); }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return
if (!IS_WEB && isMobile) return; if (!IS_WEB && isMobile) return
if (orderedVisibleOptions.length === 0) { if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1); setActiveIndex(-1)
return; return
} }
const fallbackIndex = getComboboxFallbackIndex( const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length, orderedVisibleOptions.length,
effectiveOptionsPosition effectiveOptionsPosition
); )
if (normalizedSearch) { if (normalizedSearch) {
setActiveIndex(fallbackIndex); setActiveIndex(fallbackIndex)
return; return
} }
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex); setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
}, [ }, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
effectiveOptionsPosition,
isMobile,
isOpen,
normalizedSearch,
value,
orderedVisibleOptions,
]);
const handleSelect = useCallback( const handleSelect = useCallback(
(id: string) => { (id: string) => {
onSelect(id); onSelect(id)
handleClose(); handleClose()
}, },
[handleClose, onSelect] [handleClose, onSelect]
); )
const handleSubmitSearch = useCallback(() => { const handleSubmitSearch = useCallback(() => {
if (showCustomOption) { if (showCustomOption) {
handleSelect(sanitizedSearchValue); handleSelect(sanitizedSearchValue)
} }
}, [handleSelect, sanitizedSearchValue, showCustomOption]); }, [handleSelect, sanitizedSearchValue, showCustomOption])
const handleDesktopKey = useCallback( const handleDesktopKey = useCallback(
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => { (key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
if (!isOpen) return; if (!isOpen) return
if (!IS_WEB && isMobile) return; if (!IS_WEB && isMobile) return
if (key === "ArrowDown" || key === "ArrowUp") { if (key === 'ArrowDown' || key === 'ArrowUp') {
event?.preventDefault(); event?.preventDefault()
setActiveIndex((currentIndex) => setActiveIndex((currentIndex) =>
getNextActiveIndex({ getNextActiveIndex({
currentIndex, currentIndex,
itemCount: orderedVisibleOptions.length, itemCount: orderedVisibleOptions.length,
key, key,
}) })
); )
return; return
} }
if (key === "Enter") { if (key === 'Enter') {
if (orderedVisibleOptions.length === 0) return; if (orderedVisibleOptions.length === 0) return
event?.preventDefault(); event?.preventDefault()
const index = const index =
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
handleSelect(orderedVisibleOptions[index]!.id); handleSelect(orderedVisibleOptions[index]!.id)
return; return
} }
if (key === "Escape") { if (key === 'Escape') {
event?.preventDefault(); event?.preventDefault()
handleClose(); handleClose()
} }
}, },
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions] [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
); )
useEffect(() => { useEffect(() => {
if (!IS_WEB || !isOpen) return; if (!IS_WEB || !isOpen) return
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
const key = event.key; const key = event.key
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
return; return
} }
handleDesktopKey(key, event); handleDesktopKey(key, event)
}; }
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase. // 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 () => { return () => {
window.removeEventListener("keydown", handler, true); window.removeEventListener('keydown', handler, true)
}; }
}, [handleDesktopKey, isOpen]); }, [handleDesktopKey, isOpen])
const searchInput = ( const searchInput = (
<SearchInput <SearchInput
@@ -562,7 +562,7 @@ export function Combobox({
onSubmitEditing={handleSubmitSearch} onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile} autoFocus={!isMobile}
/> />
); )
const optionsList = ( const optionsList = (
<> <>
@@ -582,17 +582,17 @@ export function Combobox({
<ComboboxEmpty>{emptyText}</ComboboxEmpty> <ComboboxEmpty>{emptyText}</ComboboxEmpty>
)} )}
</> </>
); )
const defaultContent = ( const defaultContent = (
<> <>
{effectiveOptionsPosition === "above-search" ? optionsList : null} {effectiveOptionsPosition === 'above-search' ? optionsList : null}
{searchable ? searchInput : null} {searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? optionsList : null} {effectiveOptionsPosition === 'below-search' ? optionsList : null}
</> </>
); )
const content = children ?? defaultContent; const content = children ?? defaultContent
if (isMobile) { if (isMobile) {
return ( return (
@@ -620,18 +620,13 @@ export function Combobox({
{content} {content}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </BottomSheetModal>
); )
} }
if (!isOpen) return <></>; if (!isOpen) return <></>
return ( return (
<Modal <Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
transparent
animationType="none"
visible={isOpen}
onRequestClose={handleClose}
>
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}> <View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} /> <Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View <Animated.View
@@ -641,13 +636,15 @@ export function Combobox({
style={[ style={[
styles.desktopContainer, styles.desktopContainer,
{ {
position: "absolute", position: 'absolute',
minWidth: referenceWidth ?? 200, minWidth: referenceWidth ?? 200,
maxWidth: 400, maxWidth: 400,
}, },
desktopPositionStyle, desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null, 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} ref={refs.setFloating}
collapsable={false} collapsable={false}
@@ -664,7 +661,7 @@ export function Combobox({
</ScrollView> </ScrollView>
) : ( ) : (
<> <>
{effectiveOptionsPosition === "above-search" ? ( {effectiveOptionsPosition === 'above-search' ? (
<ScrollView <ScrollView
ref={desktopOptionsScrollRef} ref={desktopOptionsScrollRef}
contentContainerStyle={[ contentContainerStyle={[
@@ -680,7 +677,7 @@ export function Combobox({
</ScrollView> </ScrollView>
) : null} ) : null}
{searchable ? searchInput : null} {searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? ( {effectiveOptionsPosition === 'below-search' ? (
<ScrollView <ScrollView
contentContainerStyle={styles.desktopScrollContent} contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -695,13 +692,13 @@ export function Combobox({
</Animated.View> </Animated.View>
</View> </View>
</Modal> </Modal>
); )
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
searchInputContainer: { searchInputContainer: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
@@ -719,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
comboboxItem: { comboboxItem: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
minHeight: 36, minHeight: 36,
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -744,9 +741,9 @@ const styles = StyleSheet.create((theme) => ({
}, },
comboboxItemTrailingSlot: { comboboxItemTrailingSlot: {
width: 16, width: 16,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
marginLeft: "auto", marginLeft: 'auto',
}, },
comboboxItemContent: { comboboxItemContent: {
flex: 1, flex: 1,
@@ -754,8 +751,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
comboboxItemLeadingSlot: { comboboxItemLeadingSlot: {
width: 16, width: 16,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}, },
comboboxItemLabel: { comboboxItemLabel: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -774,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
bottomSheetBackground: { bottomSheetBackground: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"], borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius["2xl"], borderTopRightRadius: theme.borderRadius['2xl'],
}, },
bottomSheetHandle: { bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600], backgroundColor: theme.colors.palette.zinc[600],
@@ -788,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground, color: theme.colors.foreground,
textAlign: "left", textAlign: 'left',
}, },
comboboxScrollContent: { comboboxScrollContent: {
paddingBottom: theme.spacing[8], paddingBottom: theme.spacing[8],
@@ -799,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
}, },
desktopBackdrop: { desktopBackdrop: {
position: "absolute", position: 'absolute',
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
@@ -810,13 +807,13 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
shadowColor: "#000", shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2, shadowOpacity: 0.2,
shadowRadius: 8, shadowRadius: 8,
elevation: 8, elevation: 8,
maxHeight: 400, maxHeight: 400,
overflow: "hidden", overflow: 'hidden',
}, },
desktopScroll: { desktopScroll: {
flexShrink: 1, flexShrink: 1,
@@ -827,6 +824,6 @@ const styles = StyleSheet.create((theme) => ({
}, },
desktopScrollContentAboveSearch: { desktopScrollContentAboveSearch: {
flexGrow: 1, flexGrow: 1,
justifyContent: "flex-end", justifyContent: 'flex-end',
}, },
})); }))
+4 -4
View File
@@ -1,4 +1,5 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getTauri } from "@/utils/tauri";
export const FOOTER_HEIGHT = 75; 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) // Check if running in Tauri desktop app (any OS)
function isTauri(): boolean { function isTauri(): boolean {
if (Platform.OS !== "web") return false; if (Platform.OS !== "web") return false;
if (typeof window === "undefined") return false; return getTauri() !== null;
return "__TAURI__" in window;
} }
// Check if running in Tauri desktop app on macOS // Check if running in Tauri desktop app on macOS
function isTauriMac(): boolean { function isTauriMac(): boolean {
if (Platform.OS !== "web") return false; if (Platform.OS !== "web") return false;
if (typeof window === "undefined") 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 // Check for macOS via user agent
const ua = navigator.userAgent; const ua = navigator.userAgent;
return ua.includes("Mac OS") || ua.includes("Macintosh"); 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 _isTauriMacCached: boolean | null = null;
let _isTauriCached: boolean | null = null; let _isTauriCached: boolean | null = null;
@@ -351,6 +351,15 @@ export function SessionProvider({
const isActive = appState ? appState === "active" : true; const isActive = appState ? appState === "active" : true;
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId; const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
if (!isAwayFromAgent) { if (!isAwayFromAgent) {
console.log(
"[OSNotifications] Skipping attention notification: user already focused on agent",
{
agentId: params.agentId,
reason: params.reason,
appState,
focusedAgentId,
}
);
return; return;
} }
+227 -62
View File
@@ -1,125 +1,290 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from 'react'
import type { AutocompleteOption } from "@/components/ui/autocomplete"; import { useQuery } from '@tanstack/react-query'
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query"; import type { AutocompleteOption } from '@/components/ui/autocomplete'
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
import { useAutocomplete } from "./use-autocomplete"; 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 { interface UseAgentAutocompleteInput {
userInput: string; userInput: string
setUserInput: (nextValue: string) => void; cursorIndex: number
serverId: string; setUserInput: (nextValue: string) => void
agentId: string; serverId: string
draftConfig?: DraftCommandConfig; agentId: string
onAutocompleteApplied?: () => void; draftConfig?: DraftCommandConfig
onAutocompleteApplied?: () => void
} }
type AgentAutocompleteOption =
| (AutocompleteOption & { type: 'command' })
| (AutocompleteOption & {
type: 'workspace_entry'
entryPath: string
mention: FileMentionRange
})
interface AgentAutocompleteResult { interface AgentAutocompleteResult {
isVisible: boolean; isVisible: boolean
options: AutocompleteOption[]; options: AutocompleteOption[]
selectedIndex: number; selectedIndex: number
isLoading: boolean; isLoading: boolean
errorMessage?: string; errorMessage?: string
onSelectOption: (option: AutocompleteOption) => void; loadingText: string
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; emptyText: string
onSelectOption: (option: AutocompleteOption) => void
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean
}
interface DirectorySuggestionEntry {
path: string
kind: 'file' | 'directory'
} }
function normalizeDraftCommandConfig( function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig draftConfig?: DraftCommandConfig
): DraftCommandConfig | undefined { ): DraftCommandConfig | undefined {
if (!draftConfig) { if (!draftConfig) {
return undefined; return undefined
} }
const cwd = draftConfig.cwd.trim(); const cwd = draftConfig.cwd.trim()
if (!cwd) { if (!cwd) {
return undefined; return undefined
} }
const modeId = draftConfig.modeId?.trim() ?? ""; const modeId = draftConfig.modeId?.trim() ?? ''
const model = draftConfig.model?.trim() ?? ""; const model = draftConfig.model?.trim() ?? ''
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ''
return { return {
provider: draftConfig.provider, provider: draftConfig.provider,
cwd, cwd,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(model ? { model } : {}), ...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
}; }
} }
export function useAgentAutocomplete( function mapDirectorySuggestionsToEntries(payload: {
input: UseAgentAutocompleteInput entries?: Array<{ path: string; kind: string }>
): AgentAutocompleteResult { 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 { const {
userInput, userInput,
cursorIndex,
setUserInput, setUserInput,
serverId, serverId,
agentId, agentId,
draftConfig, draftConfig,
onAutocompleteApplied, onAutocompleteApplied,
} = input; } = input
const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ')
const filterQuery = showAutocomplete ? userInput.slice(1) : ""; 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( const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig), () => normalizeDraftCommandConfig(draftConfig),
[draftConfig] [draftConfig]
); )
const isRealAgent = Boolean(agentId) && !agentId.startsWith("__"); const isRealAgent = Boolean(agentId) && !agentId.startsWith('__')
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig; const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig
const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig); const canLoadCommands = Boolean(serverId) && (isRealAgent || !!queryDraftConfig)
const isVisible = showAutocomplete && canLoadOptions;
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, serverId,
agentId, agentId,
enabled: canLoadOptions, enabled: mode === 'command' && canLoadCommands,
draftConfig: queryDraftConfig, 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) { if (!isVisible) {
return []; return []
} }
const filterLower = filterQuery.toLowerCase(); if (mode === 'command') {
const matches = commands.filter((cmd) => const filterLower = commandFilterQuery.toLowerCase()
cmd.name.toLowerCase().includes(filterLower) const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower))
); const orderedMatches = orderAutocompleteOptions(matches)
const orderedMatches = orderAutocompleteOptions(matches); return orderedMatches.map((cmd) => ({
return orderedMatches.map((cmd) => ({ type: 'command' as const,
id: cmd.name, id: cmd.name,
label: `/${cmd.name}`, label: `/${cmd.name}`,
detail: cmd.argumentHint || undefined, detail: cmd.argumentHint || undefined,
description: cmd.description, description: cmd.description,
})); kind: 'command',
}, [commands, filterQuery, isVisible]); }))
}
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( const onSelectOption = useCallback(
(option: AutocompleteOption) => { (option: AutocompleteOption) => {
setUserInput(`/${option.id} `); const selected = option as AgentAutocompleteOption
onAutocompleteApplied?.(); 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({ const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible, isVisible,
options, options,
query: filterQuery, query: mode === 'command' ? commandFilterQuery : fileFilterQuery,
onSelectOption, 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 { return {
isVisible, isVisible,
options, options,
selectedIndex, selectedIndex,
isLoading, isLoading,
errorMessage: isError ? (error?.message ?? "Failed to load") : undefined, errorMessage,
loadingText,
emptyText,
onSelectOption, onSelectOption,
onKeyPress, onKeyPress,
}; }
} }
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard"; import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { getTauri } from "@/utils/tauri";
export interface AudioCaptureConfig { export interface AudioCaptureConfig {
sampleRate?: number; sampleRate?: number;
@@ -156,16 +157,33 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true; : true;
const currentOrigin = const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); 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( throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` `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 options = configRef.current;
const constraints: MediaStreamConstraints = { const constraints: MediaStreamConstraints = {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getTauri } from "@/utils/tauri";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
@@ -148,13 +149,29 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true; : true;
const currentOrigin = const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); 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}`); 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(); const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) { if (!AudioContextCtor) {
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout"; import { getIsTauriMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents"; import { useAggregatedAgents } from "./use-aggregated-agents";
import { getCurrentTauriWindow } from "@/utils/tauri";
type FaviconStatus = "none" | "running" | "attention"; type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light"; type ColorScheme = "dark" | "light";
@@ -90,9 +91,9 @@ function getSystemColorScheme(): ColorScheme {
} }
async function updateMacDockBadge(count?: number) { 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") { if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
return; return;
} }
+4 -7
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
interface UseFileDropZoneOptions { interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void; onFilesDropped: (files: ImageAttachment[]) => void;
@@ -52,10 +53,6 @@ function isImageFile(file: File): boolean {
return file.type.startsWith("image/"); return file.type.startsWith("image/");
} }
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getFileExtension(path: string): string { function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf("."); const extensionIndex = normalizedPath.lastIndexOf(".");
@@ -72,7 +69,7 @@ function isImagePath(path: string): boolean {
function filePathToImageAttachment(path: string): ImageAttachment { function filePathToImageAttachment(path: string): ImageAttachment {
const extension = getFileExtension(path); const extension = getFileExtension(path);
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc; const convertFileSrc = getTauri()?.core?.convertFileSrc;
const uri = const uri =
typeof convertFileSrc === "function" ? convertFileSrc(path) : path; typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
@@ -145,11 +142,11 @@ export function useFileDropZone({
} }
async function setupTauriDragDrop(): Promise<boolean> { async function setupTauriDragDrop(): Promise<boolean> {
if (!isTauriEnvironment()) { if (getTauri() === null) {
return false; return false;
} }
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); const tauriWindow = getCurrentTauriWindow();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") { if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
return false; return false;
} }
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SpeechSegmenter } from "@/voice/speech-segmenter"; import { SpeechSegmenter } from "@/voice/speech-segmenter";
import { getTauri } from "@/utils/tauri";
export interface SpeechmaticsAudioConfig { export interface SpeechmaticsAudioConfig {
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void; onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
@@ -245,14 +246,30 @@ export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): Speechmat
? window.isSecureContext ? window.isSecureContext
: true; : true;
const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isTauri = getTauri() !== null;
try { try {
if (missingNavigator) { if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment"); 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}`); 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(); const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) { 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useQueries } from "@tanstack/react-query"; 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 { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
@@ -35,6 +35,14 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; 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) => const delay = (ms: number) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
@@ -76,7 +84,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
letterSpacing: 0.6,
marginBottom: theme.spacing[3], marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1], marginLeft: theme.spacing[1],
}, },
@@ -84,7 +91,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
letterSpacing: 0.4,
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
}, },
input: { input: {
@@ -301,39 +307,58 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
audioRowDescription: { permissionSectionHeader: {
color: theme.colors.foregroundMuted, flexDirection: "row",
fontSize: theme.fontSize.sm,
marginTop: 2,
},
// Footer
footer: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
alignItems: "center", 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", alignItems: "center",
justifyContent: "center",
},
permissionRefreshButtonDisabled: {
opacity: theme.opacity[50],
},
permissionRowActions: {
alignItems: "flex-end",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
footerText: { permissionStatusPill: {
color: theme.colors.foregroundMuted, flexDirection: "row",
fontSize: theme.fontSize.sm, 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, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
maxWidth: 220,
textAlign: "right",
}, },
resetButton: { aboutValue: {
paddingVertical: theme.spacing[2], color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
},
resetButtonText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
}, },
// Empty state // Empty state
emptyCard: { emptyCard: {
@@ -352,26 +377,27 @@ const styles = StyleSheet.create((theme) => ({
// Theme toggle // Theme toggle
themeToggleContainer: { themeToggleContainer: {
flexDirection: "row", flexDirection: "row",
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface3,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.md,
padding: 4, borderWidth: 1,
gap: 4, borderColor: theme.colors.border,
padding: 2,
gap: 2,
}, },
themeToggleButton: { themeToggleButton: {
flex: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: theme.spacing[2], gap: theme.spacing[1],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.sm,
}, },
themeToggleButtonActive: { themeToggleButtonActive: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface2,
}, },
themeToggleText: { themeToggleText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
@@ -417,7 +443,7 @@ export default function SettingsScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const { const {
daemons, daemons,
isLoading: daemonLoading, isLoading: daemonLoading,
@@ -436,7 +462,14 @@ export default function SettingsScreen() {
const [isRemovingHost, setIsRemovingHost] = useState(false); const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null); const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false); 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 isLoading = settingsLoading || daemonLoading;
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null); const lastHandledEditHostRef = useRef<string | null>(null);
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
@@ -534,6 +567,73 @@ export default function SettingsScreen() {
pendingEditReopenServerId, 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) => { const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
if (!editingServerId) return; if (!editingServerId) return;
if (isSavingEdit) return; if (isSavingEdit) return;
@@ -590,34 +690,6 @@ export default function SettingsScreen() {
[updateSettings] [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 = const restartConfirmationMessage =
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts."; "This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
@@ -804,55 +876,113 @@ export default function SettingsScreen() {
{/* Appearance */} {/* Appearance */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Appearance</Text> <Text style={styles.sectionTitle}>Appearance</Text>
<View style={styles.themeToggleContainer}> <View style={styles.audioCard}>
<Pressable <View style={styles.audioRow}>
style={[ <View style={styles.audioRowContent}>
styles.themeToggleButton, <Text style={styles.audioRowTitle}>Theme</Text>
settings.theme === "light" && styles.themeToggleButtonActive, </View>
]} <View style={styles.themeToggleContainer}>
onPress={() => handleThemeChange("light")} <Pressable
> style={[
<Sun size={16} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} /> styles.themeToggleButton,
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}> settings.theme === "light" && styles.themeToggleButtonActive,
Light ]}
</Text> onPress={() => handleThemeChange("light")}
</Pressable> >
<Pressable <Sun size={14} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
style={[ <Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
styles.themeToggleButton, Light
settings.theme === "dark" && styles.themeToggleButtonActive, </Text>
]} </Pressable>
onPress={() => handleThemeChange("dark")} <Pressable
> style={[
<Moon size={16} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} /> styles.themeToggleButton,
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}> settings.theme === "dark" && styles.themeToggleButtonActive,
Dark ]}
</Text> onPress={() => handleThemeChange("dark")}
</Pressable> >
<Pressable <Moon size={14} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
style={[ <Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
styles.themeToggleButton, Dark
settings.theme === "auto" && styles.themeToggleButtonActive, </Text>
]} </Pressable>
onPress={() => handleThemeChange("auto")} <Pressable
> style={[
<Monitor size={16} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} /> styles.themeToggleButton,
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}> settings.theme === "auto" && styles.themeToggleButtonActive,
System ]}
</Text> onPress={() => handleThemeChange("auto")}
</Pressable> >
<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>
</View> </View>
{/* Footer */} {showDesktopPermissionSection ? (
<View style={styles.footer}> <View style={styles.section}>
<View style={styles.footerAppInfo}> <View style={styles.permissionSectionHeader}>
<Text style={styles.footerText}>Paseo</Text> <Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
<Text style={styles.footerVersion}>Version {appVersion}</Text> 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> </View>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to defaults</Text>
</Pressable>
</View> </View>
</View> </View>
</ScrollView> </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({ function ConnectionRow({
connection, connection,
latencyMs, latencyMs,
+3 -24
View File
@@ -1,4 +1,5 @@
import { Alert, Platform } from "react-native"; import { Alert, Platform } from "react-native";
import { getTauri, type TauriDialogAskOptions } from "@/utils/tauri";
export interface ConfirmDialogInput { export interface ConfirmDialogInput {
title: string; title: string;
@@ -8,26 +9,6 @@ export interface ConfirmDialogInput {
destructive?: boolean; 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 { interface ConfirmButtonConfig {
confirmLabel: string; confirmLabel: string;
cancelLabel: string; cancelLabel: string;
@@ -67,13 +48,11 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
}); });
} }
function getTauriApi(): TauriGlobalApi | null { function getTauriApi() {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return null; return null;
} }
return getTauri();
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
return tauriApi ?? null;
} }
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions { 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 * as Linking from "expo-linking";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsTauri } from "@/constants/layout"; import { getTauri } from "@/utils/tauri";
interface TauriWindowWithOpener {
__TAURI__?: {
opener?: {
openUrl?: (url: string) => Promise<void>;
};
};
}
export async function openExternalUrl(url: string): Promise<void> { export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (typeof window !== "undefined" && getIsTauri()) { const opener = getTauri()?.opener?.openUrl;
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl; if (typeof opener === "function") {
if (typeof opener === "function") { await opener(url);
await opener(url); return;
return;
}
} }
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
@@ -18,6 +18,7 @@ type GlobalSnapshot = {
dispatchEvent: unknown; dispatchEvent: unknown;
focus: unknown; focus: unknown;
location: unknown; location: unknown;
__TAURI__: unknown;
}; };
const originalGlobals: GlobalSnapshot = { const originalGlobals: GlobalSnapshot = {
@@ -26,6 +27,7 @@ const originalGlobals: GlobalSnapshot = {
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent, dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
focus: (globalThis as { focus?: unknown }).focus, focus: (globalThis as { focus?: unknown }).focus,
location: (globalThis as { location?: unknown }).location, location: (globalThis as { location?: unknown }).location,
__TAURI__: (globalThis as { __TAURI__?: unknown }).__TAURI__,
}; };
async function loadModuleForPlatform(platform: "web" | "ios" | "android") { async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
@@ -40,6 +42,7 @@ function restoreGlobals(): void {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent; (globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
(globalThis as { focus?: unknown }).focus = originalGlobals.focus; (globalThis as { focus?: unknown }).focus = originalGlobals.focus;
(globalThis as { location?: unknown }).location = originalGlobals.location; (globalThis as { location?: unknown }).location = originalGlobals.location;
(globalThis as { __TAURI__?: unknown }).__TAURI__ = originalGlobals.__TAURI__;
} }
describe("sendOsNotification", () => { describe("sendOsNotification", () => {
@@ -163,4 +166,33 @@ describe("sendOsNotification", () => {
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); 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 { Platform } from "react-native";
import { buildNotificationRoute } from "./notification-routing"; import { buildNotificationRoute } from "./notification-routing";
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
type OsNotificationPayload = { type OsNotificationPayload = {
title: string; title: string;
@@ -21,6 +22,13 @@ export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null; let permissionRequest: Promise<boolean> | null = null;
function getTauriNotificationModule(): TauriNotificationApi | null {
if (Platform.OS !== "web") {
return null;
}
return getTauri()?.notification ?? null;
}
function getWebNotificationConstructor(): { function getWebNotificationConstructor(): {
permission: string; permission: string;
requestPermission?: () => Promise<string>; requestPermission?: () => Promise<string>;
@@ -54,6 +62,86 @@ async function ensureNotificationPermission(): Promise<boolean> {
return result; 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 { function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent; const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent }) const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
@@ -127,12 +215,28 @@ export async function sendOsNotification(
return false; return false;
} }
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
console.log("[OSNotifications] Using Tauri notification module");
return await sendTauriNotification(payload, tauriNotification);
}
const NotificationConstructor = getWebNotificationConstructor(); const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) { if (!NotificationConstructor) {
console.log(
"[OSNotifications][Web] Notification constructor unavailable",
typeof window !== "undefined" && window.location
? window.location.origin
: "unknown-origin"
);
return false; return false;
} }
const granted = await ensureNotificationPermission(); const granted = await ensureOsNotificationPermission();
if (!granted) { if (!granted) {
console.log(
"[OSNotifications][Web] Permission not granted:",
NotificationConstructor.permission
);
return false; return false;
} }
const notification = new NotificationConstructor(payload.title, { const notification = new NotificationConstructor(payload.title, {
@@ -1,4 +1,5 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client"; import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import { getTauri } from "@/utils/tauri";
type TauriWebSocketMessage = type TauriWebSocketMessage =
| { type: "Text"; data: string } | { type: "Text"; data: string }
@@ -29,15 +30,8 @@ function toTauriOutgoingMessage(
return Array.from(data); return Array.from(data);
} }
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getTauriWebSocketModule(): TauriWebSocketModule | null { function getTauriWebSocketModule(): TauriWebSocketModule | null {
if (!isTauriEnvironment()) { const ws = getTauri()?.websocket;
return null;
}
const ws = (window as any).__TAURI__?.websocket;
if (ws && typeof ws.connect === "function") { if (ws && typeof ws.connect === "function") {
return ws as TauriWebSocketModule; return ws as TauriWebSocketModule;
} }
@@ -45,7 +39,7 @@ function getTauriWebSocketModule(): TauriWebSocketModule | null {
} }
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null { export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
if (!isTauriEnvironment()) { if (getTauri() === null) {
return null; return null;
} }
@@ -122,7 +116,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
} }
if (!module) { if (!module) {
throw new Error( 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 { Platform } from "react-native";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout"; import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
import { getCurrentTauriWindow, getTauri, isTauriEnvironment } from "@/utils/tauri";
let tauriWindow: any = null; let tauriWindow: any = null;
// Runtime check for Tauri environment
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" &&
(window as any).__TAURI__ !== undefined;
}
async function getTauriWindow() { async function getTauriWindow() {
if (tauriWindow) return tauriWindow; if (tauriWindow) return tauriWindow;
@@ -19,15 +14,18 @@ async function getTauriWindow() {
} }
try { 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 // We must use that here because importing `@tauri-apps/api/*` would be bundled into
// the native (Hermes) builds and break parsing/runtime on mobile. // 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. // Prefer the public global window module.
const windowModule = tauri?.window; const publicWindow = getCurrentTauriWindow();
if (windowModule && typeof windowModule.getCurrentWindow === "function") { if (publicWindow) {
tauriWindow = windowModule.getCurrentWindow(); tauriWindow = publicWindow;
return tauriWindow; 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;
}
}
+69 -2
View File
@@ -2028,6 +2028,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.14.1"
@@ -2164,6 +2176,20 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify-rust"
version = "4.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -2512,7 +2538,7 @@ dependencies = [
[[package]] [[package]]
name = "paseo" name = "paseo"
version = "0.1.0" version = "0.1.13"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -2521,6 +2547,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-websocket", "tauri-plugin-websocket",
] ]
@@ -2708,7 +2735,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -2858,6 +2885,15 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -4035,6 +4071,25 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.3" version = "2.5.3"
@@ -4178,6 +4233,18 @@ dependencies = [
"toml 0.9.11+spec-1.1.0", "toml 0.9.11+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.25.0" version = "3.25.0"
+3 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "paseo" name = "paseo"
version = "0.1.0" version = "0.1.13"
description = "Paseo Desktop" description = "Paseo Desktop"
authors = ["moboudra"] authors = ["moboudra"]
license = "MIT" license = "MIT"
@@ -28,5 +28,7 @@ log = "0.4"
tauri = { version = "2.9.5", features = [] } tauri = { version = "2.9.5", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-websocket = "2" tauri-plugin-websocket = "2"
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+2 -2
View File
@@ -7,9 +7,9 @@
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Paseo</string> <string>Paseo</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.1.0</string> <string>0.1.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.1.0</string> <string>0.1.13</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string> <string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
</dict> </dict>
@@ -17,6 +17,7 @@
"core:window:allow-set-badge-count", "core:window:allow-set-badge-count",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"dialog:default", "dialog:default",
"notification:default",
"opener:default", "opener:default",
"websocket:default" "websocket:default"
] ]
+27
View File
@@ -1,5 +1,7 @@
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu}; use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
#[cfg(target_os = "macos")]
use tauri::menu::AboutMetadata;
use tauri::Manager; use tauri::Manager;
use tauri::WebviewWindow; use tauri::WebviewWindow;
@@ -20,6 +22,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_websocket::init())
.setup(|app| { .setup(|app| {
@@ -38,6 +41,30 @@ pub fn run() {
// responder-chain shortcuts across the whole app. // responder-chain shortcuts across the whole app.
let menu = Menu::default(app.handle())?; let menu = Menu::default(app.handle())?;
#[cfg(target_os = "macos")]
{
let app_menu = menu.items()?.into_iter().find_map(|item| match item {
MenuItemKind::Submenu(submenu) => Some(submenu),
_ => None,
});
if let Some(submenu) = app_menu {
// Tauri's default about item sets only `version`, which macOS renders as
// "Version <plist short> (<version>)". Set only `short_version` instead.
let about_metadata = AboutMetadata {
name: Some(app.package_info().name.clone()),
short_version: Some(app.package_info().version.to_string()),
copyright: app.config().bundle.copyright.clone(),
..Default::default()
};
let about = PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
if submenu.remove_at(0)?.is_some() {
submenu.insert(&about, 0)?;
}
}
}
let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In") let zoom_in = MenuItemBuilder::with_id("zoom_in", "Zoom In")
.accelerator("CmdOrCtrl+=") .accelerator("CmdOrCtrl+=")
.build(app)?; .build(app)?;
+2 -2
View File
@@ -2,8 +2,8 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle), // `tauri dev` runs the raw executable (`target/debug/Paseo`) on macOS (not the .app bundle),
// so we must embed an Info.plist containing `NSMicrophoneUsageDescription` for WebKit // so we must embed an Info.plist containing usage descriptions for WebKit media
// microphone access to trigger the OS permission prompt in dev. // permission prompts in dev.
#[cfg(all(target_os = "macos", debug_assertions))] #[cfg(all(target_os = "macos", debug_assertions))]
tauri::embed_plist::embed_info_plist!("../Info.plist"); tauri::embed_plist::embed_info_plist!("../Info.plist");
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Paseo", "productName": "Paseo",
"version": "0.1.0", "version": "0.1.13",
"identifier": "dev.paseo.desktop", "identifier": "dev.paseo.desktop",
"build": { "build": {
"frontendDist": "../../app/dist", "frontendDist": "../../app/dist",
@@ -20,7 +20,10 @@
"fullscreen": false, "fullscreen": false,
"titleBarStyle": "Overlay", "titleBarStyle": "Overlay",
"hiddenTitle": true, "hiddenTitle": true,
"trafficLightPosition": { "x": 16, "y": 22 } "trafficLightPosition": {
"x": 16,
"y": 22
}
} }
], ],
"security": { "security": {
@@ -38,6 +41,7 @@
"icons/icon.ico" "icons/icon.ico"
], ],
"macOS": { "macOS": {
"entitlements": "Entitlements.plist",
"infoPlist": "Info.plist" "infoPlist": "Info.plist"
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -51,6 +51,7 @@ describe("claude agent commands E2E", () => {
// These are skills that come from CLAUDE.md configurations // These are skills that come from CLAUDE.md configurations
// At minimum we should have some commands available // At minimum we should have some commands available
expect(commandNames.length).toBeGreaterThan(0); expect(commandNames.length).toBeGreaterThan(0);
expect(commandNames).toContain("rewind");
}, 60000); }, 60000);
test("returns error for non-existent agent", async () => { test("returns error for non-existent agent", async () => {
@@ -85,6 +85,7 @@ const hasClaudeCredentials =
// Should have at least one command (skills are loaded from user/project settings) // Should have at least one command (skills are loaded from user/project settings)
// The exact commands depend on what skills are configured // The exact commands depend on what skills are configured
expect(commands.length).toBeGreaterThan(0); expect(commands.length).toBeGreaterThan(0);
expect(commandNames).toContain("rewind");
await session.close(); await session.close();
}, 30000); }, 30000);
@@ -789,6 +789,63 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
120_000 120_000
); );
test(
"supports /rewind by reverting the latest file changes",
async () => {
const cwd = tmpCwd();
const client = new ClaudeAgentClient({ logger });
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config);
const filePath = path.join(cwd, "rewind-target.txt");
const tokenA = `REWIND_A_${Date.now().toString(36)}`;
const tokenB = `REWIND_B_${Date.now().toString(36)}`;
const runPrompt = async (prompt: string): Promise<void> => {
for await (const event of session.stream(prompt)) {
await autoApprove(session, event);
if (event.type === "turn_failed") {
throw new Error(event.error);
}
if (event.type === "turn_completed") {
break;
}
}
};
try {
await runPrompt(
[
"Create a file named rewind-target.txt in the current directory.",
`Set the file content to exactly: ${tokenA}`,
"Do not add extra text or commentary.",
].join(" ")
);
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain(tokenA);
await runPrompt(
[
"Edit rewind-target.txt in place.",
`Replace the entire file content with exactly: ${tokenB}`,
"Do not add extra text or commentary.",
].join(" ")
);
expect(readFileSync(filePath, "utf8")).toContain(tokenB);
const rewind = await session.run("/rewind");
expect(rewind.finalText.toLowerCase()).toContain("rewound");
const contentAfterRewind = readFileSync(filePath, "utf8");
expect(contentAfterRewind).toContain(tokenA);
expect(contentAfterRewind).not.toContain(tokenB);
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
},
240_000
);
test( test(
"resumes a persisted session with context preserved", "resumes a persisted session with context preserved",
async () => { async () => {
@@ -212,6 +212,21 @@ const VALID_CLAUDE_MODES = new Set(
DEFAULT_MODES.map((mode) => mode.id) DEFAULT_MODES.map((mode) => mode.id)
); );
const REWIND_COMMAND_NAME = "rewind";
const REWIND_COMMAND: AgentSlashCommand = {
name: REWIND_COMMAND_NAME,
description: "Rewind tracked files to a previous user message",
argumentHint: "[user_message_uuid]",
};
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type SlashCommandInvocation = {
commandName: string;
args?: string;
rawInput: string;
};
// Orchestrator instructions moved to shared module. // Orchestrator instructions moved to shared module.
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" }; type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
@@ -672,6 +687,7 @@ class ClaudeAgentSession implements AgentSession {
private activeSidechains = new Map<string, string>(); private activeSidechains = new Map<string, string>();
private compacting = false; private compacting = false;
private queryRestartNeeded = false; private queryRestartNeeded = false;
private userMessageIds: string[] = [];
constructor( constructor(
config: ClaudeAgentConfig, config: ClaudeAgentConfig,
@@ -787,6 +803,12 @@ class ClaudeAgentSession implements AgentSession {
// Reset cancel flag at the start of each turn to prevent stale state from previous turns // Reset cancel flag at the start of each turn to prevent stale state from previous turns
this.turnCancelRequested = false; this.turnCancelRequested = false;
const slashCommand = this.resolveSlashCommandInvocation(prompt);
if (slashCommand?.commandName === REWIND_COMMAND_NAME) {
yield* this.streamRewindCommand(slashCommand);
return;
}
const sdkMessage = this.toSdkUserMessage(prompt); const sdkMessage = this.toSdkUserMessage(prompt);
const queue = new Pushable<AgentStreamEvent>(); const queue = new Pushable<AgentStreamEvent>();
this.eventQueue = queue; this.eventQueue = queue;
@@ -1007,11 +1029,284 @@ class ClaudeAgentSession implements AgentSession {
async listCommands(): Promise<AgentSlashCommand[]> { async listCommands(): Promise<AgentSlashCommand[]> {
const q = await this.ensureQuery(); const q = await this.ensureQuery();
const commands = await q.supportedCommands(); const commands = await q.supportedCommands();
return commands.map((cmd) => ({ const commandMap = new Map<string, AgentSlashCommand>();
name: cmd.name, for (const cmd of commands) {
description: cmd.description, if (!commandMap.has(cmd.name)) {
argumentHint: cmd.argumentHint, commandMap.set(cmd.name, {
})); name: cmd.name,
description: cmd.description,
argumentHint: cmd.argumentHint,
});
}
}
if (!commandMap.has(REWIND_COMMAND_NAME)) {
commandMap.set(REWIND_COMMAND_NAME, REWIND_COMMAND);
}
return Array.from(commandMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}
private resolveSlashCommandInvocation(
prompt: AgentPromptInput
): SlashCommandInvocation | null {
if (typeof prompt !== "string") {
return null;
}
const parsed = this.parseSlashCommandInput(prompt);
if (!parsed) {
return null;
}
return parsed.commandName === REWIND_COMMAND_NAME ? parsed : null;
}
private parseSlashCommandInput(text: string): SlashCommandInvocation | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
return null;
}
const withoutPrefix = trimmed.slice(1);
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
const commandName =
firstWhitespaceIdx === -1
? withoutPrefix
: withoutPrefix.slice(0, firstWhitespaceIdx);
if (!commandName || commandName.includes("/")) {
return null;
}
const rawArgs =
firstWhitespaceIdx === -1
? ""
: withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
return rawArgs.length > 0
? { commandName, args: rawArgs, rawInput: trimmed }
: { commandName, rawInput: trimmed };
}
private async *streamRewindCommand(
invocation: SlashCommandInvocation
): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: "claude" };
yield {
type: "timeline",
provider: "claude",
item: { type: "user_message", text: invocation.rawInput },
};
try {
const rewindAttempt = await this.attemptRewind(invocation.args);
if (!rewindAttempt.messageId || !rewindAttempt.result) {
yield {
type: "turn_failed",
provider: "claude",
error:
rewindAttempt.error ??
"No prior user message available to rewind. Use /rewind <user_message_uuid>.",
};
return;
}
yield {
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: this.buildRewindSuccessMessage(
rewindAttempt.messageId,
rewindAttempt.result
),
},
};
yield { type: "turn_completed", provider: "claude" };
} catch (error) {
yield {
type: "turn_failed",
provider: "claude",
error:
error instanceof Error
? error.message
: "Failed to rewind tracked files",
};
}
}
private buildRewindSuccessMessage(
targetUserMessageId: string,
rewindResult: { filesChanged?: string[]; insertions?: number; deletions?: number }
): string {
const fileCount = Array.isArray(rewindResult.filesChanged)
? rewindResult.filesChanged.length
: undefined;
const stats: string[] = [];
if (typeof fileCount === "number") {
stats.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`);
}
if (typeof rewindResult.insertions === "number") {
stats.push(`${rewindResult.insertions} insertions`);
}
if (typeof rewindResult.deletions === "number") {
stats.push(`${rewindResult.deletions} deletions`);
}
if (stats.length > 0) {
return `Rewound tracked files to message ${targetUserMessageId} (${stats.join(", ")}).`;
}
return `Rewound tracked files to message ${targetUserMessageId}.`;
}
private async attemptRewind(
args: string | undefined
): Promise<{
messageId: string | null;
result?: { filesChanged?: string[]; insertions?: number; deletions?: number };
error?: string;
}> {
if (typeof args === "string" && args.trim().length > 0) {
const candidate = args.trim().split(/\s+/)[0] ?? "";
if (!UUID_PATTERN.test(candidate)) {
return {
messageId: null,
error:
"Invalid message UUID. Usage: /rewind <user_message_uuid> or /rewind",
};
}
const rewindResult = await this.rewindFilesOnce(candidate);
if (rewindResult.canRewind) {
return { messageId: candidate, result: rewindResult };
}
return {
messageId: null,
error:
rewindResult.error ??
`No file checkpoint found for message ${candidate}.`,
};
}
const candidates = this.getRewindCandidateUserMessageIds();
if (candidates.length === 0) {
return {
messageId: null,
error:
"No prior user message available to rewind. Use /rewind <user_message_uuid>.",
};
}
let lastError: string | undefined;
for (const candidate of candidates) {
try {
const rewindResult = await this.rewindFilesOnce(candidate);
if (rewindResult.canRewind) {
return { messageId: candidate, result: rewindResult };
}
if (rewindResult.error) {
lastError = rewindResult.error;
}
} catch (error) {
lastError =
error instanceof Error
? error.message
: "Failed to rewind tracked files.";
}
}
return {
messageId: null,
error:
lastError ??
"No rewind checkpoints are currently available for this session.",
};
}
private async rewindFilesOnce(messageId: string): Promise<{
canRewind: boolean;
error?: string;
filesChanged?: string[];
insertions?: number;
deletions?: number;
}> {
try {
const query = await this.ensureFreshQuery();
return await query.rewindFiles(messageId, { dryRun: false });
} catch (error) {
// The Claude SDK transport can close after a rewind call.
// If that happens, mark the query stale so a follow-up attempt uses a fresh query.
this.queryRestartNeeded = true;
throw error;
}
}
private async ensureFreshQuery(): Promise<Query> {
if (this.query) {
this.queryRestartNeeded = true;
}
return this.ensureQuery();
}
private getRewindCandidateUserMessageIds(): string[] {
const candidates: string[] = [];
const pushUnique = (value: string | null | undefined) => {
if (
typeof value === "string" &&
value.length > 0 &&
!candidates.includes(value)
) {
candidates.push(value);
}
};
const historyIds = this.readUserMessageIdsFromHistoryFile();
for (let idx = historyIds.length - 1; idx >= 0; idx -= 1) {
pushUnique(historyIds[idx]);
}
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
const item = this.persistedHistory[idx];
if (item?.type === "user_message") {
pushUnique(item.messageId);
}
}
for (let idx = this.userMessageIds.length - 1; idx >= 0; idx -= 1) {
pushUnique(this.userMessageIds[idx]);
}
return candidates;
}
private readUserMessageIdsFromHistoryFile(): string[] {
if (!this.claudeSessionId) {
return [];
}
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
return [];
}
try {
const ids: string[] = [];
const content = fs.readFileSync(historyPath, "utf8");
for (const line of content.split(/\n+/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed);
if (entry?.type === "user" && typeof entry.uuid === "string") {
ids.push(entry.uuid);
}
} catch {
// ignore malformed lines
}
}
return ids;
} catch {
return [];
}
}
private rememberUserMessageId(messageId: string | null | undefined): void {
if (typeof messageId !== "string" || messageId.length === 0) {
return;
}
const last = this.userMessageIds[this.userMessageIds.length - 1];
if (last === messageId) {
return;
}
this.userMessageIds.push(messageId);
} }
private async primeSelectableModelIds(query: Query): Promise<void> { private async primeSelectableModelIds(query: Query): Promise<void> {
@@ -1095,6 +1390,8 @@ class ClaudeAgentSession implements AgentSession {
MCP_TIMEOUT: "600000", MCP_TIMEOUT: "600000",
MCP_TOOL_TIMEOUT: "600000", MCP_TOOL_TIMEOUT: "600000",
}, },
// Required for provider-level /rewind support.
enableFileCheckpointing: true,
// If we have a session ID from a previous query (e.g., after interrupt), // If we have a session ID from a previous query (e.g., after interrupt),
// resume that session to continue the conversation history. // resume that session to continue the conversation history.
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}), ...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
@@ -1174,6 +1471,9 @@ class ClaudeAgentSession implements AgentSession {
content.push({ type: "text", text: prompt }); content.push({ type: "text", text: prompt });
} }
const messageId = randomUUID();
this.rememberUserMessageId(messageId);
return { return {
type: "user", type: "user",
message: { message: {
@@ -1181,6 +1481,7 @@ class ClaudeAgentSession implements AgentSession {
content, content,
}, },
parent_tool_use_id: null, parent_tool_use_id: null,
uuid: messageId,
session_id: this.claudeSessionId ?? "", session_id: this.claudeSessionId ?? "",
}; };
} }
@@ -1426,17 +1727,34 @@ class ClaudeAgentSession implements AgentSession {
this.compacting = false; this.compacting = false;
break; break;
} }
const messageId =
typeof message.uuid === "string" && message.uuid.length > 0
? message.uuid
: undefined;
this.rememberUserMessageId(messageId);
const content = message.message?.content; const content = message.message?.content;
if (typeof content === "string" && content.length > 0) { if (typeof content === "string" && content.length > 0) {
// String content from user messages (e.g., local command output) // String content from user messages (e.g., local command output)
events.push({ events.push({
type: "timeline", type: "timeline",
item: { type: "user_message", text: content }, item: {
type: "user_message",
text: content,
...(messageId ? { messageId } : {}),
},
provider: "claude", provider: "claude",
}); });
} else if (Array.isArray(content)) { } else if (Array.isArray(content)) {
const timelineItems = this.mapBlocksToTimeline(content, { turnContext }); const timelineItems = this.mapBlocksToTimeline(content, { turnContext });
for (const item of timelineItems) { for (const item of timelineItems) {
if (item.type === "user_message" && messageId && !item.messageId) {
events.push({
type: "timeline",
item: { ...item, messageId },
provider: "claude",
});
continue;
}
events.push({ type: "timeline", item, provider: "claude" }); events.push({ type: "timeline", item, provider: "claude" });
} }
} }
@@ -1723,6 +2041,9 @@ class ClaudeAgentSession implements AgentSession {
if (entry.isSidechain) { if (entry.isSidechain) {
continue; continue;
} }
if (entry.type === "user" && typeof entry.uuid === "string") {
this.rememberUserMessageId(entry.uuid);
}
const items = this.convertHistoryEntry(entry); const items = this.convertHistoryEntry(entry);
if (items.length > 0) { if (items.length > 0) {
timeline.push(...items); timeline.push(...items);
@@ -2300,9 +2621,14 @@ export function convertClaudeHistoryEntry(
if (entry.type === "user") { if (entry.type === "user") {
const text = extractUserMessageText(content); const text = extractUserMessageText(content);
if (text) { if (text) {
const messageId =
typeof entry.uuid === "string" && entry.uuid.length > 0
? entry.uuid
: undefined;
timeline.push({ timeline.push({
type: "user_message", type: "user_message",
text, text,
...(messageId ? { messageId } : {}),
}); });
} }
} }
@@ -12,6 +12,7 @@ import type {
const sdkMocks = vi.hoisted(() => ({ const sdkMocks = vi.hoisted(() => ({
query: vi.fn(), query: vi.fn(),
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
})); }));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
@@ -65,6 +66,7 @@ function buildSdkQueryMock() {
setModel: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []), supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
}; };
} }
@@ -91,7 +93,11 @@ describe("ClaudeAgentSession history replay regression", () => {
let previousClaudeConfigDir: string | undefined; let previousClaudeConfigDir: string | undefined;
beforeEach(() => { beforeEach(() => {
sdkMocks.query.mockImplementation(() => buildSdkQueryMock()); sdkMocks.query.mockImplementation(() => {
const mock = buildSdkQueryMock();
sdkMocks.lastQuery = mock;
return mock;
});
tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-")); tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-"));
cwd = path.join(tempRoot, "repo"); cwd = path.join(tempRoot, "repo");
@@ -107,6 +113,7 @@ describe("ClaudeAgentSession history replay regression", () => {
[ [
JSON.stringify({ JSON.stringify({
type: "user", type: "user",
uuid: "history-user-uuid",
sessionId: "history-session", sessionId: "history-session",
cwd, cwd,
message: { message: {
@@ -133,6 +140,7 @@ describe("ClaudeAgentSession history replay regression", () => {
afterEach(() => { afterEach(() => {
sdkMocks.query.mockReset(); sdkMocks.query.mockReset();
sdkMocks.lastQuery = null;
if (previousClaudeConfigDir === undefined) { if (previousClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR; delete process.env.CLAUDE_CONFIG_DIR;
} else { } else {
@@ -206,4 +214,67 @@ describe("ClaudeAgentSession history replay regression", () => {
expect(timelineText).toContain(HISTORY_USER_MARKER); expect(timelineText).toContain(HISTORY_USER_MARKER);
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
}); });
test("listCommands includes rewind command", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
nativeHandle: "history-session",
metadata: {
provider: "claude",
cwd,
},
};
const session = await client.resumeSession(handle, { cwd });
try {
const commands = await session.listCommands?.();
expect(commands?.some((command) => command.name === "rewind")).toBe(true);
} finally {
await session.close();
}
});
test("slash /rewind uses latest user message id from persisted history", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
nativeHandle: "history-session",
metadata: {
provider: "claude",
cwd,
},
};
const session = await client.resumeSession(handle, { cwd });
const events: AgentStreamEvent[] = [];
try {
for await (const event of session.stream("/rewind")) {
events.push(event);
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
break;
}
}
} finally {
await session.close();
}
expect(events.some((event) => event.type === "turn_started")).toBe(true);
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
expect(sdkMocks.lastQuery).toBeTruthy();
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith(
"history-user-uuid",
{ dryRun: false }
);
});
}); });
File diff suppressed because it is too large Load Diff
@@ -570,7 +570,19 @@ class FakeAgentSession implements AgentSession {
return commands; return commands;
} }
// claude/opencode: keep stable/deterministic. // Keep deterministic defaults for non-codex providers.
if (this.providerName === "claude") {
return [
{ name: "help", description: "Help", argumentHint: "" },
{ name: "context", description: "Context", argumentHint: "" },
{
name: "rewind",
description: "Rewind tracked files to a previous user message",
argumentHint: "[user_message_uuid]",
},
];
}
return [ return [
{ name: "help", description: "Help", argumentHint: "" }, { name: "help", description: "Help", argumentHint: "" },
{ name: "context", description: "Context", argumentHint: "" }, { name: "context", description: "Context", argumentHint: "" },
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from 'vitest'
import { import {
AgentStreamMessageSchema, AgentStreamMessageSchema,
@@ -6,172 +6,176 @@ import {
SessionInboundMessageSchema, SessionInboundMessageSchema,
SessionOutboundMessageSchema, SessionOutboundMessageSchema,
WSOutboundMessageSchema, WSOutboundMessageSchema,
} from "./messages.js"; } from './messages.js'
describe("shared messages stream parsing", () => { describe('shared messages stream parsing', () => {
it("parses representative fetch_agent_timeline_response payload", () => { it('parses representative fetch_agent_timeline_response payload', () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({ const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response", type: 'fetch_agent_timeline_response',
payload: { payload: {
requestId: "req-1", requestId: 'req-1',
agentId: "agent_live", agentId: 'agent_live',
direction: "tail", direction: 'tail',
projection: "projected", projection: 'projected',
epoch: "epoch-1", epoch: 'epoch-1',
reset: false, reset: false,
staleCursor: false, staleCursor: false,
gap: false, gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 }, window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 }, startCursor: { epoch: 'epoch-1', seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 }, endCursor: { epoch: 'epoch-1', seq: 2 },
hasOlder: false, hasOlder: false,
hasNewer: false, hasNewer: false,
entries: [ entries: [
{ {
provider: "codex", provider: 'codex',
item: { type: "assistant_message", text: "hello" }, item: { type: 'assistant_message', text: 'hello' },
timestamp: "2026-02-08T20:10:00.000Z", timestamp: '2026-02-08T20:10:00.000Z',
seqStart: 1, seqStart: 1,
seqEnd: 2, seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"], collapsed: ['assistant_merge'],
}, },
], ],
error: null, error: null,
}, },
}); })
expect(parsed.payload.entries).toHaveLength(1); expect(parsed.payload.entries).toHaveLength(1)
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message"); expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
}); })
it("parses representative agent_stream tool_call event", () => { it('parses representative agent_stream tool_call event', () => {
const parsed = AgentStreamMessageSchema.parse({ const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream", type: 'agent_stream',
payload: { payload: {
agentId: "agent_live", agentId: 'agent_live',
timestamp: "2026-02-08T20:10:00.000Z", timestamp: '2026-02-08T20:10:00.000Z',
event: { event: {
type: "timeline", type: 'timeline',
provider: "claude", provider: 'claude',
item: { item: {
type: "tool_call", type: 'tool_call',
callId: "call_live", callId: 'call_live',
name: "shell", name: 'shell',
status: "running", status: 'running',
detail: { detail: {
type: "shell", type: 'shell',
command: "ls", command: 'ls',
}, },
error: null, error: null,
}, },
}, },
}, },
}); })
expect(parsed.payload.event.type).toBe("timeline"); expect(parsed.payload.event.type).toBe('timeline')
if (parsed.payload.event.type === "timeline") { if (parsed.payload.event.type === 'timeline') {
expect(parsed.payload.event.item.type).toBe("tool_call"); expect(parsed.payload.event.item.type).toBe('tool_call')
if (parsed.payload.event.item.type === "tool_call") { if (parsed.payload.event.item.type === 'tool_call') {
expect(parsed.payload.event.item.status).toBe("running"); expect(parsed.payload.event.item.status).toBe('running')
} }
} }
}); })
it("rejects removed initialize_agent_request inbound payload", () => { it('rejects removed initialize_agent_request inbound payload', () => {
const parsed = SessionInboundMessageSchema.safeParse({ const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request", type: 'initialize_agent_request',
agentId: "agent-legacy", agentId: 'agent-legacy',
requestId: "req-legacy-1", requestId: 'req-legacy-1',
}); })
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false)
}); })
it("rejects removed initialize_agent_request outbound payload", () => { it('rejects removed initialize_agent_request outbound payload', () => {
const parsed = SessionOutboundMessageSchema.safeParse({ const parsed = SessionOutboundMessageSchema.safeParse({
type: "initialize_agent_request", type: 'initialize_agent_request',
payload: { payload: {
requestId: "req-legacy-1", requestId: 'req-legacy-1',
agentId: "agent-legacy", agentId: 'agent-legacy',
agentStatus: "running", agentStatus: 'running',
timelineSize: 12, timelineSize: 12,
}, },
}); })
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false)
}); })
it("parses directory suggestions request and response payloads", () => { it('parses directory suggestions request and response payloads', () => {
const requestParsed = SessionInboundMessageSchema.safeParse({ const requestParsed = SessionInboundMessageSchema.safeParse({
type: "directory_suggestions_request", type: 'directory_suggestions_request',
query: "proj", query: 'proj',
cwd: '/tmp/project',
includeFiles: true,
includeDirectories: true,
limit: 20, limit: 20,
requestId: "req-dir-1", requestId: 'req-dir-1',
}); })
expect(requestParsed.success).toBe(true); expect(requestParsed.success).toBe(true)
const responseParsed = SessionOutboundMessageSchema.safeParse({ const responseParsed = SessionOutboundMessageSchema.safeParse({
type: "directory_suggestions_response", type: 'directory_suggestions_response',
payload: { payload: {
directories: ["/Users/test/projects/paseo"], directories: ['/Users/test/projects/paseo'],
entries: [{ path: '/Users/test/projects/paseo', kind: 'directory' }],
error: null, error: null,
requestId: "req-dir-1", requestId: 'req-dir-1',
}, },
}); })
expect(responseParsed.success).toBe(true); expect(responseParsed.success).toBe(true)
}); })
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => { it('rejects websocket envelope for removed agent_stream_snapshot message type', () => {
const fixture = { const fixture = {
type: "agent_stream_snapshot", type: 'agent_stream_snapshot',
payload: { payload: {
agentId: "agent-legacy", agentId: 'agent-legacy',
events: [], events: [],
}, },
}; }
const wrapped = WSOutboundMessageSchema.safeParse({ const wrapped = WSOutboundMessageSchema.safeParse({
type: "session", type: 'session',
message: fixture, message: fixture,
}); })
expect(wrapped.success).toBe(false); expect(wrapped.success).toBe(false)
}); })
it("rejects removed legacy git diff request messages", () => { it('rejects removed legacy git diff request messages', () => {
const gitDiffParsed = SessionInboundMessageSchema.safeParse({ const gitDiffParsed = SessionInboundMessageSchema.safeParse({
type: "git_diff_request", type: 'git_diff_request',
agentId: "agent-1", agentId: 'agent-1',
requestId: "req-1", requestId: 'req-1',
}); })
expect(gitDiffParsed.success).toBe(false); expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionInboundMessageSchema.safeParse({ const highlightedParsed = SessionInboundMessageSchema.safeParse({
type: "highlighted_diff_request", type: 'highlighted_diff_request',
agentId: "agent-1", agentId: 'agent-1',
requestId: "req-2", requestId: 'req-2',
}); })
expect(highlightedParsed.success).toBe(false); expect(highlightedParsed.success).toBe(false)
}); })
it("rejects removed legacy git diff response messages", () => { it('rejects removed legacy git diff response messages', () => {
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({ const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
type: "git_diff_response", type: 'git_diff_response',
payload: { payload: {
agentId: "agent-1", agentId: 'agent-1',
diff: "", diff: '',
error: null, error: null,
requestId: "req-1", requestId: 'req-1',
}, },
}); })
expect(gitDiffParsed.success).toBe(false); expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionOutboundMessageSchema.safeParse({ const highlightedParsed = SessionOutboundMessageSchema.safeParse({
type: "highlighted_diff_response", type: 'highlighted_diff_response',
payload: { payload: {
agentId: "agent-1", agentId: 'agent-1',
files: [], files: [],
error: null, error: null,
requestId: "req-2", requestId: 'req-2',
}, },
}); })
expect(highlightedParsed.success).toBe(false); expect(highlightedParsed.success).toBe(false)
}); })
}); })
File diff suppressed because it is too large Load Diff
@@ -471,6 +471,109 @@ describe("checkout git utilities", () => {
} }
}); });
it("returns merged PR status when no open PR exists for the current branch", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-merged");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"if [[ \"${1-}\" == \"--version\" ]]; then",
" echo \"gh version 2.0.0\"",
" exit 0",
"fi",
"args=\"$*\"",
"if [[ \"$args\" == *\"state=open\"* ]]; then",
" echo \"[]\"",
" exit 0",
"fi",
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/123\",\"title\":\"Ship feature\",\"state\":\"closed\",\"merged_at\":\"2026-02-18T00:00:00Z\",\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
" exit 0",
"fi",
"echo \"unexpected gh args: $args\" >&2",
"exit 1",
"",
].join("\n"),
"utf8"
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
const originalPath = process.env.PATH;
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
try {
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/123");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(true);
expect(status.status?.state).toBe("merged");
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
}
});
it("does not treat closed-unmerged PRs as shipped status", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-closed");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"if [[ \"${1-}\" == \"--version\" ]]; then",
" echo \"gh version 2.0.0\"",
" exit 0",
"fi",
"args=\"$*\"",
"if [[ \"$args\" == *\"state=open\"* ]]; then",
" echo \"[]\"",
" exit 0",
"fi",
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/999\",\"title\":\"Closed without merge\",\"state\":\"closed\",\"merged_at\":null,\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
" exit 0",
"fi",
"echo \"unexpected gh args: $args\" >&2",
"exit 1",
"",
].join("\n"),
"utf8"
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
const originalPath = process.env.PATH;
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
try {
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).toBeNull();
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
}
});
it("returns typed MergeConflictError on merge conflicts", async () => { it("returns typed MergeConflictError on merge conflicts", async () => {
const conflictFile = join(repoDir, "conflict.txt"); const conflictFile = join(repoDir, "conflict.txt");
writeFileSync(conflictFile, "base\n"); writeFileSync(conflictFile, "base\n");
+103 -25
View File
@@ -1517,6 +1517,7 @@ export interface PullRequestStatus {
state: string; state: string;
baseRefName: string; baseRefName: string;
headRefName: string; headRefName: string;
isMerged: boolean;
} }
export interface PullRequestStatusResult { export interface PullRequestStatusResult {
@@ -1592,6 +1593,62 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> {
return null; return null;
} }
async function listPullRequestsForHead(options: {
cwd: string;
repo: string;
owner: string;
head: string;
state: "open" | "closed";
}): Promise<any[]> {
const { stdout } = await execFileAsync(
"gh",
[
"api",
`repos/${options.repo}/pulls`,
"-X",
"GET",
"-F",
`head=${options.owner}:${options.head}`,
"-F",
`state=${options.state}`,
],
{ cwd: options.cwd }
);
const parsed = JSON.parse(stdout.trim());
return Array.isArray(parsed) ? parsed : [];
}
function buildPullRequestStatus(current: any, fallbackHead: string): PullRequestStatus | null {
if (!current || typeof current !== "object") {
return null;
}
const url = current.html_url ?? current.url;
const title = current.title;
if (typeof url !== "string" || typeof title !== "string" || !url || !title) {
return null;
}
const mergedAt =
typeof current.merged_at === "string" && current.merged_at.trim().length > 0
? current.merged_at
: null;
const state =
mergedAt !== null
? "merged"
: typeof current.state === "string" && current.state.trim().length > 0
? current.state
: "";
return {
url,
title,
state,
baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? fallbackHead,
isMerged: mergedAt !== null,
};
}
export async function createPullRequest( export async function createPullRequest(
cwd: string, cwd: string,
options: CreatePullRequestOptions options: CreatePullRequestOptions
@@ -1652,22 +1709,15 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
}; };
} }
const owner = repo.split("/")[0]; const owner = repo.split("/")[0];
let stdout: string; let openPulls: any[];
try { try {
({ stdout } = await execFileAsync( openPulls = await listPullRequestsForHead({
"gh", cwd,
[ repo,
"api", owner,
`repos/${repo}/pulls`, head,
"-X", state: "open",
"GET", });
"-F",
`head=${owner}:${head}`,
"-F",
"state=open",
],
{ cwd }
));
} catch (error) { } catch (error) {
if (isGhAuthError(error)) { if (isGhAuthError(error)) {
return { return {
@@ -1677,22 +1727,50 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
} }
throw error; throw error;
} }
const parsed = JSON.parse(stdout.trim()); const openPull = openPulls[0] ?? null;
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null; const openStatus = buildPullRequestStatus(openPull, head);
if (!current) { if (openStatus) {
return {
status: openStatus,
githubFeaturesEnabled: true,
};
}
let closedPulls: any[];
try {
closedPulls = await listPullRequestsForHead({
cwd,
repo,
owner,
head,
state: "closed",
});
} catch (error) {
if (isGhAuthError(error)) {
return {
status: null,
githubFeaturesEnabled: false,
};
}
throw error;
}
const mergedClosedPull =
closedPulls.find(
(entry) =>
entry &&
typeof entry === "object" &&
typeof entry.merged_at === "string" &&
entry.merged_at.trim().length > 0
) ?? null;
const mergedStatus = buildPullRequestStatus(mergedClosedPull, head);
if (!mergedStatus) {
return { return {
status: null, status: null,
githubFeaturesEnabled: true, githubFeaturesEnabled: true,
}; };
} }
return { return {
status: { status: mergedStatus,
url: current.html_url ?? current.url,
title: current.title,
state: current.state,
baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? head,
},
githubFeaturesEnabled: true, githubFeaturesEnabled: true,
}; };
} }
@@ -1,165 +1,237 @@
import { import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
mkdtempSync, import { tmpdir } from 'node:os'
mkdirSync, import path from 'node:path'
realpathSync, import { afterEach, beforeEach, describe, expect, it } from 'vitest'
rmSync, import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories } from "./directory-suggestions.js";
describe("searchHomeDirectories", () => { describe('searchHomeDirectories', () => {
let tempRoot: string; let tempRoot: string
let homeDir: string; let homeDir: string
let outsideDir: string; let outsideDir: string
beforeEach(() => { beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
homeDir = path.join(tempRoot, "home"); homeDir = path.join(tempRoot, 'home')
outsideDir = path.join(tempRoot, "outside"); outsideDir = path.join(tempRoot, 'outside')
mkdirSync(homeDir, { recursive: true }); mkdirSync(homeDir, { recursive: true })
mkdirSync(outsideDir, { recursive: true }); mkdirSync(outsideDir, { recursive: true })
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true }); mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true }); mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
}); })
afterEach(() => { afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true }); rmSync(tempRoot, { recursive: true, force: true })
}); })
it("returns an empty list for blank queries", async () => { it('returns an empty list for blank queries', async () => {
await expect( await expect(
searchHomeDirectories({ searchHomeDirectories({
homeDir, homeDir,
query: " ", query: ' ',
limit: 10, limit: 10,
}) })
).resolves.toEqual([]); ).resolves.toEqual([])
}); })
it("returns only existing directories", async () => { it('returns only existing directories', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "proj", query: 'proj',
limit: 10, limit: 10,
}); })
expect(results).toContain(path.join(homeDir, "projects")); expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, "projects", "paseo")); expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
}); })
it("supports home-relative path query syntax", async () => { it('supports home-relative path query syntax', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "~/projects/pa", query: '~/projects/pa',
limit: 10, limit: 10,
}); })
expect(results).toEqual([ expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
path.join(homeDir, "projects", "paseo"), })
]);
});
it("prioritizes exact segment matches before segment-prefix matches", async () => { it('prioritizes exact segment matches before segment-prefix matches', async () => {
const exactSegmentPath = path.join( const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
homeDir, const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
"something", mkdirSync(exactSegmentPath, { recursive: true })
"faro", mkdirSync(prefixSegmentPath, { recursive: true })
"something-else"
);
const prefixSegmentPath = path.join(
homeDir,
"something",
"somethingelse",
"faro-bla"
);
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "faro", query: 'faro',
limit: 30, limit: 30,
}); })
const exactIndex = results.indexOf(exactSegmentPath); const exactIndex = results.indexOf(exactSegmentPath)
const prefixIndex = results.indexOf(prefixSegmentPath); const prefixIndex = results.indexOf(prefixSegmentPath)
expect(exactIndex).toBeGreaterThanOrEqual(0); expect(exactIndex).toBeGreaterThanOrEqual(0)
expect(prefixIndex).toBeGreaterThanOrEqual(0); expect(prefixIndex).toBeGreaterThanOrEqual(0)
expect(exactIndex).toBeLessThan(prefixIndex); expect(exactIndex).toBeLessThan(prefixIndex)
}); })
it("prioritizes partial matches that appear earlier in the path", async () => { it('prioritizes partial matches that appear earlier in the path', async () => {
const earlierPath = path.join(homeDir, "farofoo"); const earlierPath = path.join(homeDir, 'farofoo')
const laterPath = path.join(homeDir, "x", "y", "farofoo"); const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
mkdirSync(earlierPath, { recursive: true }); mkdirSync(earlierPath, { recursive: true })
mkdirSync(laterPath, { recursive: true }); mkdirSync(laterPath, { recursive: true })
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "arofo", query: 'arofo',
limit: 30, limit: 30,
}); })
const earlierIndex = results.indexOf(earlierPath); const earlierIndex = results.indexOf(earlierPath)
const laterIndex = results.indexOf(laterPath); const laterIndex = results.indexOf(laterPath)
expect(earlierIndex).toBeGreaterThanOrEqual(0); expect(earlierIndex).toBeGreaterThanOrEqual(0)
expect(laterIndex).toBeGreaterThanOrEqual(0); expect(laterIndex).toBeGreaterThanOrEqual(0)
expect(earlierIndex).toBeLessThan(laterIndex); expect(earlierIndex).toBeLessThan(laterIndex)
}); })
it("returns home-root suggestions when query is '~'", async () => { it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "~", query: '~',
limit: 20, limit: 20,
}); })
expect(results).toContain(path.join(homeDir, "projects")); expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, "documents")); expect(results).toContain(path.join(homeDir, 'documents'))
expect(results).not.toContain(path.join(homeDir, ".hidden")); expect(results).not.toContain(path.join(homeDir, '.hidden'))
}); })
it("does not return hidden directories during tree search", async () => { it('does not return hidden directories during tree search', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "cache", query: 'cache',
limit: 20, limit: 20,
}); })
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache")); expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
}); })
it("does not return paths that escape home through symlinks", async () => { it('does not return paths that escape home through symlinks', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "outside", query: 'outside',
limit: 20, limit: 20,
}); })
expect(results).not.toContain(path.join(homeDir, "outside-link")); expect(results).not.toContain(path.join(homeDir, 'outside-link'))
expect(results).not.toContain(path.join(outsideDir, "outside-match")); expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
}); })
it("respects the result limit", async () => { it('respects the result limit', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "p", query: 'p',
limit: 1, limit: 1,
}); })
expect(results).toHaveLength(1); expect(results).toHaveLength(1)
}); })
}); })
describe('searchWorkspaceEntries', () => {
let tempRoot: string
let workspaceDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
workspaceDir = path.join(tempRoot, 'workspace')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
recursive: true,
})
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
writeFileSync(
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
'export const ChatInput = null;\n'
)
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
it('returns relative file and directory suggestions for workspace queries', async () => {
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'chat',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(results).toContainEqual({
path: 'src/components/chat-input.tsx',
kind: 'file',
})
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
})
it('filters entries by kind', async () => {
const dirsOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src',
limit: 20,
includeFiles: false,
includeDirectories: true,
})
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
const filesOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'readme',
limit: 20,
includeFiles: true,
includeDirectories: false,
})
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
})
it('supports path-style queries and does not escape cwd through symlinks', async () => {
const pathResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src/co',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(pathResults).toContainEqual({
path: 'src/components',
kind: 'directory',
})
const escapedResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'escaped',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
+104
View File
@@ -23,6 +23,106 @@ const dependencySections = [
const touched = [] const touched = []
function syncDesktopTauriConfigVersion(version) {
const tauriConfigPath = path.join(
rootDir,
'packages',
'desktop',
'src-tauri',
'tauri.conf.json'
)
if (!existsSync(tauriConfigPath)) {
return
}
const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, 'utf8'))
if (tauriConfig.version === version) {
return
}
tauriConfig.version = version
writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`)
touched.push(path.relative(rootDir, tauriConfigPath))
}
function syncDesktopCargoPackageVersion(version) {
const cargoTomlPath = path.join(
rootDir,
'packages',
'desktop',
'src-tauri',
'Cargo.toml'
)
if (!existsSync(cargoTomlPath)) {
return
}
const cargoLines = readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/)
let inPackageSection = false
let updated = false
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) {
inPackageSection = true
return line
}
if (inPackageSection && /^\[/.test(line)) {
inPackageSection = false
return line
}
if (inPackageSection && /^version\s*=\s*".*"\s*$/.test(line)) {
const expectedLine = `version = "${version}"`
if (line === expectedLine) {
return line
}
updated = true
return expectedLine
}
return line
})
if (!updated) {
return
}
writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`)
touched.push(path.relative(rootDir, cargoTomlPath))
}
function syncDesktopInfoPlistVersion(version) {
const infoPlistPath = path.join(
rootDir,
'packages',
'desktop',
'src-tauri',
'Info.plist'
)
if (!existsSync(infoPlistPath)) {
return
}
const current = readFileSync(infoPlistPath, 'utf8')
const shortVersionPattern = /(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/
const bundleVersionPattern = /(<key>CFBundleVersion<\/key>\s*<string>)([^<]+)(<\/string>)/
let next = current.replace(shortVersionPattern, `$1${version}$3`)
next = next.replace(bundleVersionPattern, `$1${version}$3`)
if (next === current) {
return
}
writeFileSync(infoPlistPath, next)
touched.push(path.relative(rootDir, infoPlistPath))
}
for (const workspacePath of workspacePaths) { for (const workspacePath of workspacePaths) {
const packagePath = path.join(rootDir, workspacePath, 'package.json') const packagePath = path.join(rootDir, workspacePath, 'package.json')
if (!existsSync(packagePath)) { if (!existsSync(packagePath)) {
@@ -63,6 +163,10 @@ for (const workspacePath of workspacePaths) {
} }
} }
syncDesktopTauriConfigVersion(rootVersion)
syncDesktopCargoPackageVersion(rootVersion)
syncDesktopInfoPlistVersion(rootVersion)
if (touched.length === 0) { if (touched.length === 0) {
console.log(`Workspace versions and internal deps already synced to ${rootVersion}`) console.log(`Workspace versions and internal deps already synced to ${rootVersion}`)
} else { } else {