feat: improve agent workspace flows and directory suggestions

This commit is contained in:
Mohamed Boudra
2026-02-16 23:19:01 +07:00
parent 22da0951d5
commit 170ee27813
49 changed files with 3053 additions and 293 deletions
@@ -883,11 +883,9 @@ export function WorkingDirectoryDropdown({
options={options}
value={workingDir}
onSelect={onSelectPath}
searchPlaceholder="/path/to/project"
searchPlaceholder="Search directories..."
emptyText={emptyText}
allowCustomValue
customValuePrefix="Use"
customValueDescription="Launch the agent in this directory"
optionsPosition="above-search"
title="Working directory"
open={isOpen}
onOpenChange={handleOpenChange}
@@ -105,12 +105,6 @@ export function AgentList({
return;
}
// Clear attention flag when opening agent
const session = useSessionStore.getState().sessions[serverId];
if (session?.client) {
session.client.clearAgentAttention(agentId);
}
const navigationKey = buildAgentNavigationKey(serverId, agentId);
startNavigationTiming(navigationKey, {
from: "home",
@@ -14,7 +14,6 @@ import {
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import {
Archive,
ChevronDown,
@@ -52,6 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import { openExternalUrl } from "@/utils/open-external-url";
// =============================================================================
// Git Actions Data Structure
@@ -85,11 +85,7 @@ interface GitActions {
}
function openURLInNewTab(url: string): void {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener");
} else {
void Linking.openURL(url);
}
void openExternalUrl(url);
}
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
+2 -6
View File
@@ -20,7 +20,6 @@ import {
} from "react";
import type { ReactNode, ComponentType } from "react";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import * as Linking from "expo-linking";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -71,6 +70,7 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
export type { InlinePathTarget } from "@/utils/inline-path";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -540,11 +540,7 @@ export const AssistantMessage = memo(function AssistantMessage({
);
const handleLinkPress = useCallback((url: string) => {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener,noreferrer");
} else {
void Linking.openURL(url);
}
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
@@ -592,11 +592,6 @@ export function SidebarAgentList({
return;
}
const session = useSessionStore.getState().sessions[entry.agent.serverId];
if (session?.client) {
session.client.clearAgentAttention(entry.agent.id);
}
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
startNavigationTiming(navigationKey, {
from: "home",
@@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers } from "@/utils/tauri-window";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { Combobox } from "@/components/ui/combobox";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
@@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
closeGestureRef,
} = useSidebarAnimation();
const dragHandlers = useTauriDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -465,7 +466,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
<View style={styles.sidebarHeader} {...dragHandlers}>
<View
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
{...dragHandlers}
>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
+36 -17
View File
@@ -15,12 +15,14 @@ import Svg, {
Stop,
} from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { useSessionStore } from "@/stores/session-store";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import {
TerminalOutputPump,
type TerminalOutputChunk,
@@ -89,6 +91,8 @@ type PendingTerminalInput =
};
};
type ListTerminalsPayload = ListTerminalsResponse["payload"];
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -134,6 +138,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
@@ -245,8 +250,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
const terminalsQuery = useQuery({
queryKey: ["terminals", serverId, cwd] as const,
queryKey: terminalsQueryKey,
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
queryFn: async () => {
if (!client) {
@@ -281,14 +290,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
setModifiers({ ...EMPTY_MODIFIERS });
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
});
}, [client, cwd, isConnected, queryClient, serverId]);
}, [client, isConnected, queryClient, terminalsQueryKey]);
useEffect(() => {
if (!client || !isConnected || !cwd.startsWith("/")) {
@@ -303,10 +312,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return;
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
});
@@ -317,7 +326,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
unsubscribe();
client.unsubscribeTerminals({ cwd });
};
}, [client, cwd, isConnected, queryClient, serverId]);
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
const createTerminalMutation = useMutation({
mutationFn: async () => {
@@ -327,12 +336,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return await client.createTerminal(cwd);
},
onSuccess: (payload) => {
if (payload.terminal) {
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
setSelectedTerminalId(payload.terminal.id);
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
return {
cwd: current?.cwd ?? cwd,
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
setSelectedTerminalId(createdTerminal.id);
requestTerminalFocus();
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
},
});
@@ -358,10 +381,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
setModifiers({ ...EMPTY_MODIFIERS });
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
queryKey: terminalsQueryKey,
type: "active",
});
},
@@ -550,10 +573,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
[killTerminalMutation]
);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
const clearPendingModifiers = useCallback(() => {
setModifiers({ ...EMPTY_MODIFIERS });
}, []);
+65 -16
View File
@@ -19,7 +19,7 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, Search } from "lucide-react-native";
import { Check, Folder, Search } from "lucide-react-native";
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
@@ -29,6 +29,7 @@ export interface ComboboxOption {
id: string;
label: string;
description?: string;
kind?: "directory";
}
export interface ComboboxProps {
@@ -43,6 +44,8 @@ export interface ComboboxProps {
allowCustomValue?: boolean;
customValuePrefix?: string;
customValueDescription?: string;
customValueKind?: "directory";
optionsPosition?: "below-search" | "above-search";
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -129,6 +132,7 @@ function SearchInput({
export interface ComboboxItemProps {
label: string;
description?: string;
kind?: "directory";
selected?: boolean;
active?: boolean;
onPress: () => void;
@@ -138,6 +142,7 @@ export interface ComboboxItemProps {
export function ComboboxItem({
label,
description,
kind,
selected,
active,
onPress,
@@ -155,6 +160,11 @@ export function ComboboxItem({
active && styles.comboboxItemActive,
]}
>
{kind === "directory" ? (
<View style={styles.comboboxItemLeadingSlot}>
<Folder size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
<View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
{description ? (
@@ -186,6 +196,8 @@ export function Combobox({
allowCustomValue = false,
customValuePrefix = "Use",
customValueDescription,
customValueKind,
optionsPosition = "below-search",
title = "Select",
open,
onOpenChange,
@@ -196,6 +208,8 @@ export function Combobox({
}: ComboboxProps): ReactElement {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveOptionsPosition =
isMobile ? "below-search" : optionsPosition;
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
@@ -375,13 +389,20 @@ export function Combobox({
id: string;
label: string;
description?: string;
kind?: "directory";
}> = [];
if (showCustomOption) {
const trimmedPrefix = customValuePrefix.trim();
const customLabel =
trimmedPrefix.length > 0
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
next.push({
id: sanitizedSearchValue,
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
label: customLabel,
description: customValueDescription,
kind: customValueKind,
});
}
@@ -390,35 +411,54 @@ export function Combobox({
id: opt.id,
label: opt.label,
description: opt.description,
kind: opt.kind,
});
}
return next;
}, [
customValueDescription,
customValueKind,
customValuePrefix,
filteredOptions,
sanitizedSearchValue,
showCustomOption,
]);
const orderedVisibleOptions = useMemo(() => {
if (effectiveOptionsPosition !== "above-search") {
return visibleOptions;
}
return [...visibleOptions].reverse();
}, [effectiveOptionsPosition, visibleOptions]);
useEffect(() => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (visibleOptions.length === 0) {
if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1);
return;
}
const fallbackIndex =
effectiveOptionsPosition === "above-search" ? orderedVisibleOptions.length - 1 : 0;
if (normalizedSearch) {
setActiveIndex(0);
setActiveIndex(fallbackIndex);
return;
}
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
}, [
effectiveOptionsPosition,
isMobile,
isOpen,
normalizedSearch,
value,
orderedVisibleOptions,
]);
const handleSelect = useCallback(
(id: string) => {
@@ -444,7 +484,7 @@ export function Combobox({
setActiveIndex((currentIndex) =>
getNextActiveIndex({
currentIndex,
itemCount: visibleOptions.length,
itemCount: orderedVisibleOptions.length,
key,
})
);
@@ -452,11 +492,11 @@ export function Combobox({
}
if (key === "Enter") {
if (visibleOptions.length === 0) return;
if (orderedVisibleOptions.length === 0) return;
event?.preventDefault();
const index =
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
handleSelect(visibleOptions[index]!.id);
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
handleSelect(orderedVisibleOptions[index]!.id);
return;
}
@@ -465,7 +505,7 @@ export function Combobox({
handleClose();
}
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
);
useEffect(() => {
@@ -498,12 +538,13 @@ export function Combobox({
const optionsList = (
<>
{visibleOptions.length > 0 ? (
visibleOptions.map((opt, index) => (
{orderedVisibleOptions.length > 0 ? (
orderedVisibleOptions.map((opt, index) => (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
kind={opt.kind}
selected={opt.id === value}
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
@@ -515,13 +556,16 @@ export function Combobox({
</>
);
const content = children ?? (
const defaultContent = (
<>
{effectiveOptionsPosition === "above-search" ? optionsList : null}
{searchable ? searchInput : null}
{optionsList}
{effectiveOptionsPosition === "below-search" ? optionsList : null}
</>
);
const content = children ?? defaultContent;
if (isMobile) {
return (
<BottomSheetModal
@@ -662,6 +706,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
flexShrink: 1,
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
@@ -216,9 +216,6 @@ export function useCommandCenter() {
const handleSelectAgent = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId];
session?.client?.clearAgentAttention(agent.id);
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
+39 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
type FaviconStatus = "none" | "running" | "attention";
@@ -34,6 +35,21 @@ function deriveFaviconStatus(
return "none";
}
function deriveMacDockBadgeCount(
agents: ReturnType<typeof useAggregatedAgents>["agents"]
): number | undefined {
const attentionCount = agents.filter(
(agent) =>
agent.requiresAttention &&
(agent.attentionReason === "permission" || agent.attentionReason === "finished")
).length;
if (attentionCount > 0) {
return attentionCount;
}
return undefined;
}
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
const image = FAVICON_IMAGES[colorScheme][status];
if (typeof image === "object" && "uri" in image) {
@@ -73,9 +89,25 @@ function getSystemColorScheme(): ColorScheme {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return;
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") {
return;
}
try {
await tauriWindow.setBadgeCount(count);
} catch (error) {
console.warn("[useFaviconStatus] Failed to update macOS dock badge", error);
}
}
export function useFaviconStatus() {
const { agents } = useAggregatedAgents();
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
// Listen for system color scheme changes
useEffect(() => {
@@ -96,5 +128,11 @@ export function useFaviconStatus() {
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
const dockBadgeCount = deriveMacDockBadgeCount(agents);
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}
}, [agents, colorScheme]);
}
+198 -60
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
@@ -13,11 +13,75 @@ interface UseFileDropZoneReturn {
}
const IS_WEB = Platform.OS === "web";
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
};
type TauriDragDropPayload =
| {
type: "enter";
paths: string[];
}
| {
type: "over";
}
| {
type: "drop";
paths: string[];
}
| {
type: "leave";
};
type TauriDragDropEvent = {
payload: TauriDragDropPayload;
};
function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf(".");
if (extensionIndex < 0) {
return "";
}
return normalizedPath.slice(extensionIndex).toLowerCase();
}
function isImagePath(path: string): boolean {
return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION;
}
function filePathToImageAttachment(path: string): ImageAttachment {
const extension = getFileExtension(path);
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc;
const uri =
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
return {
uri,
mimeType,
};
}
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -62,78 +126,152 @@ export function useFileDropZone({
useEffect(() => {
if (!IS_WEB) return;
const element = containerRef.current;
if (!element) return;
let disposed = false;
let cleanup: (() => void) | undefined;
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer?.types.includes("Files")) {
setIsDragging(true);
async function setupTauriDragDrop(): Promise<boolean> {
if (!isTauriEnvironment()) {
return false;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
return false;
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const imageFiles = files.filter(isImageFile);
if (imageFiles.length === 0) return;
try {
const attachments = await Promise.all(
imageFiles.map(fileToImageAttachment)
const unlisten = await tauriWindow.onDragDropEvent(
(event: TauriDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
return;
}
if (payload.type === "enter" || payload.type === "over") {
if (!disabled) {
setIsDragging(true);
}
return;
}
// Drop always ends the current drag operation.
setIsDragging(false);
if (disabled) return;
const imagePaths = payload.paths.filter(isImagePath);
if (imagePaths.length === 0) {
return;
}
const attachments = imagePaths.map(filePathToImageAttachment);
onFilesDroppedRef.current(attachments);
}
);
onFilesDroppedRef.current(attachments);
if (disposed) {
unlisten();
return true;
}
cleanup = unlisten;
return true;
} catch (error) {
console.error("[useFileDropZone] Failed to process dropped files:", error);
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
return false;
}
}
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragleave", handleDragLeave);
element.addEventListener("drop", handleDrop);
function setupDomDragDrop() {
const element = containerRef.current;
if (!element) {
return;
}
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer?.types.includes("Files")) {
setIsDragging(true);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const imageFiles = files.filter(isImageFile);
if (imageFiles.length === 0) return;
try {
const attachments = await Promise.all(
imageFiles.map(fileToImageAttachment)
);
onFilesDroppedRef.current(attachments);
} catch (error) {
console.error("[useFileDropZone] Failed to process dropped files:", error);
}
}
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragleave", handleDragLeave);
element.addEventListener("drop", handleDrop);
cleanup = () => {
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragleave", handleDragLeave);
element.removeEventListener("drop", handleDrop);
};
}
void (async () => {
const tauriListenersAttached = await setupTauriDragDrop();
if (disposed || tauriListenersAttached) {
return;
}
setupDomDragDrop();
})();
return () => {
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragleave", handleDragLeave);
element.removeEventListener("drop", handleDrop);
disposed = true;
cleanup?.();
};
}, [disabled]);
@@ -72,6 +72,7 @@ import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import {
DropdownMenu,
@@ -512,6 +513,9 @@ function AgentScreenContent({
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null
);
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
kind: "idle",
@@ -860,25 +864,30 @@ function AgentScreenContent({
document.title = title;
}, [agent?.title]);
// Track previous agent status to detect completion while viewing
const previousStatusRef = useRef<string | null>(null);
// Clear attention when agent finishes while user is viewing this screen
// Clear attention as soon as the user is focused on this agent screen.
useEffect(() => {
if (!resolvedAgentId || !agent || !client) {
const clearAgentId = resolvedAgentId?.trim();
if (!clearAgentId || !client) {
return;
}
const previousStatus = previousStatusRef.current;
const currentStatus = agent.status;
previousStatusRef.current = currentStatus;
// If agent transitioned from running to idle while we're viewing,
// immediately clear attention since user witnessed the completion
if (previousStatus === "running" && currentStatus === "idle") {
client.clearAgentAttention(resolvedAgentId);
if (
!shouldClearAgentAttentionOnView({
agentId: clearAgentId,
focusedAgentId,
isConnected,
requiresAttention: agent?.requiresAttention,
})
) {
return;
}
}, [resolvedAgentId, agent?.status, client]);
client.clearAgentAttention(clearAgentId);
}, [
agent?.requiresAttention,
client,
focusedAgentId,
isConnected,
resolvedAgentId,
]);
const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId) {
@@ -27,10 +27,13 @@ import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
} from "@/hooks/use-checkout-status-query";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { shortenPath } from "@/utils/shorten-path";
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -232,6 +235,8 @@ export function DraftAgentScreen({
const [isBranchOpen, setIsBranchOpen] = useState(false);
const [branchSearchQuery, setBranchSearchQuery] = useState("");
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState("");
const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState("");
const workingDirAnchorRef = useRef<View>(null);
const worktreeAnchorRef = useRef<View>(null);
const branchAnchorRef = useRef<View>(null);
@@ -246,6 +251,12 @@ export function DraftAgentScreen({
return () => clearTimeout(timer);
}, [branchSearchQuery]);
useEffect(() => {
const trimmed = workingDirSearchQuery.trim();
const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180);
return () => clearTimeout(timer);
}, [workingDirSearchQuery]);
type CreateAttempt = {
messageId: string;
text: string;
@@ -307,6 +318,7 @@ export function DraftAgentScreen({
const sessionAgents = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
);
const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId });
const worktreePathLastCreatedAt = useMemo(() => {
const map = new Map<string, number>();
if (!sessionAgents) {
@@ -325,24 +337,23 @@ export function DraftAgentScreen({
return map;
}, [sessionAgents]);
const agentWorkingDirSuggestions = useMemo(() => {
if (!selectedServerId || !sessionAgents) {
return [];
}
const pathLastCreated = new Map<string, Date>();
sessionAgents.forEach((agent) => {
if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) {
const existing = pathLastCreated.get(agent.cwd);
if (!existing || agent.createdAt > existing) {
pathLastCreated.set(agent.cwd, agent.createdAt);
}
}
});
return Array.from(pathLastCreated.keys()).sort((a, b) => {
const aTime = pathLastCreated.get(a)!.getTime();
const bTime = pathLastCreated.get(b)!.getTime();
return bTime - aTime;
});
}, [selectedServerId, sessionAgents]);
const liveSources = sessionAgents
? Array.from(sessionAgents.values()).map((agent) => ({
cwd: agent.cwd,
createdAt: agent.createdAt,
lastActivityAt: agent.lastActivityAt,
}))
: [];
const fetchedSources = allAgents.map((agent) => ({
cwd: agent.cwd,
lastActivityAt: agent.lastActivityAt,
}));
return collectAgentWorkingDirectorySuggestions([
...liveSources,
...fetchedSources,
]);
}, [allAgents, sessionAgents]);
const sessionClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
@@ -511,6 +522,36 @@ export function DraftAgentScreen({
staleTime: 15_000,
});
const directorySuggestionsQuery = useQuery({
queryKey: [
"directorySuggestions",
selectedServerId,
debouncedWorkingDirSearchQuery,
],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getDirectorySuggestions({
query: debouncedWorkingDirSearchQuery,
limit: 50,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.directories ?? [];
},
enabled:
Boolean(debouncedWorkingDirSearchQuery) &&
Boolean(selectedServerId) &&
!daemonAvailabilityError &&
Boolean(sessionClient) &&
isConnected,
retry: false,
staleTime: 15_000,
});
const validateWorktreeName = useCallback(
(name: string): { valid: boolean; error?: string } => {
if (!name) {
@@ -644,6 +685,50 @@ export function DraftAgentScreen({
const selectedWorktreeLabel =
worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
const workingDirSearchError =
directorySuggestionsQuery.error instanceof Error
? directorySuggestionsQuery.error.message
: null;
const workingDirSuggestionPaths = useMemo(
() =>
buildWorkingDirectorySuggestions({
recommendedPaths: agentWorkingDirSuggestions,
serverPaths: hasWorkingDirectorySearch ? (directorySuggestionsQuery.data ?? []) : [],
query: workingDirSearchQuery,
}),
[
agentWorkingDirSuggestions,
directorySuggestionsQuery.data,
hasWorkingDirectorySearch,
workingDirSearchQuery,
]
);
const workingDirComboOptions = useMemo(
() =>
workingDirSuggestionPaths.map((path) => ({
id: path,
label: shortenPath(path),
kind: "directory" as const,
})),
[workingDirSuggestionPaths]
);
const workingDirEmptyText = useMemo(() => {
if (hasWorkingDirectorySearch) {
if (workingDirSearchError) {
return "Failed to search directories on this host.";
}
return "No directories match your search.";
}
return agentWorkingDirSuggestions.length > 0
? "No agent directories match your search."
: "We'll suggest directories from agents on this host once they exist.";
}, [
agentWorkingDirSuggestions.length,
hasWorkingDirectorySearch,
workingDirSearchError,
]);
const displayWorkingDir = shortenPath(workingDir);
const worktreeTriggerValue =
worktreeMode === "create"
@@ -1100,21 +1185,13 @@ export function DraftAgentScreen({
/>
<Combobox
options={agentWorkingDirSuggestions.map((path) => ({
id: path,
label: shortenPath(path),
}))}
options={workingDirComboOptions}
value={workingDir}
onSelect={setWorkingDirFromUser}
searchPlaceholder="/path/to/project"
emptyText={
agentWorkingDirSuggestions.length > 0
? "No agent directories match your search."
: "We'll suggest directories from agents on this host once they exist."
}
allowCustomValue
customValuePrefix="Use"
customValueDescription="Launch the agent in this directory"
onSearchQueryChange={setWorkingDirSearchQuery}
searchPlaceholder="Search directories..."
emptyText={workingDirEmptyText}
optionsPosition="above-search"
title="Working directory"
open={isWorkingDirOpen}
onOpenChange={setIsWorkingDirOpen}
+2 -1
View File
@@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import type { HostProfile } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url";
interface DownloadProgress {
percent: number;
@@ -303,7 +304,7 @@ function buildDownloadUrl(
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener");
void openExternalUrl(url);
}
return;
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { shouldClearAgentAttentionOnView } from "./agent-attention";
describe("shouldClearAgentAttentionOnView", () => {
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
})
).toBe(true);
});
it("returns false when the app is disconnected", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: false,
requiresAttention: true,
})
).toBe(false);
});
it("returns false when the agent is not focused", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "agent-1",
focusedAgentId: "agent-2",
isConnected: true,
requiresAttention: true,
})
).toBe(false);
});
it("returns false when attention is already clear", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: false,
})
).toBe(false);
});
it("returns false for empty agent ids", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
})
).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
interface ShouldClearAgentAttentionOnViewInput {
agentId: string | null | undefined;
focusedAgentId: string | null | undefined;
isConnected: boolean;
requiresAttention: boolean | null | undefined;
}
export function shouldClearAgentAttentionOnView(
input: ShouldClearAgentAttentionOnViewInput
): boolean {
const agentId = input.agentId?.trim();
if (!agentId) {
return false;
}
if (!input.isConnected) {
return false;
}
if (!input.requiresAttention) {
return false;
}
return input.focusedAgentId === agentId;
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
describe("collectAgentWorkingDirectorySuggestions", () => {
it("deduplicates by cwd and sorts by most recent timestamp", () => {
const results = collectAgentWorkingDirectorySuggestions([
{
cwd: "/Users/me/project-alpha",
createdAt: new Date("2026-02-10T10:00:00.000Z"),
},
{
cwd: "/Users/me/project-beta",
createdAt: new Date("2026-02-11T10:00:00.000Z"),
},
{
cwd: "/Users/me/project-alpha",
lastActivityAt: new Date("2026-02-12T10:00:00.000Z"),
},
]);
expect(results).toEqual([
"/Users/me/project-alpha",
"/Users/me/project-beta",
]);
});
it("excludes Paseo-owned worktree paths", () => {
const results = collectAgentWorkingDirectorySuggestions([
{
cwd: "/Users/me/repo/.paseo/worktrees/feature-a",
createdAt: new Date("2026-02-12T10:00:00.000Z"),
},
{
cwd: "/Users/me/repo",
createdAt: new Date("2026-02-10T10:00:00.000Z"),
},
{
cwd: "C:\\Users\\me\\repo\\.paseo\\worktrees\\feature-b",
createdAt: new Date("2026-02-11T10:00:00.000Z"),
},
]);
expect(results).toEqual(["/Users/me/repo"]);
});
it("ignores empty cwd values", () => {
const results = collectAgentWorkingDirectorySuggestions([
{ cwd: " ", createdAt: new Date("2026-02-10T10:00:00.000Z") },
{ cwd: null, createdAt: new Date("2026-02-11T10:00:00.000Z") },
{ cwd: undefined, lastActivityAt: new Date("2026-02-12T10:00:00.000Z") },
{
cwd: "/Users/me/project",
createdAt: new Date("2026-02-09T10:00:00.000Z"),
},
]);
expect(results).toEqual(["/Users/me/project"]);
});
});
@@ -0,0 +1,51 @@
export interface AgentWorkingDirectorySource {
cwd?: string | null;
createdAt?: Date | null;
lastActivityAt?: Date | null;
}
const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/;
export function collectAgentWorkingDirectorySuggestions(
sources: Iterable<AgentWorkingDirectorySource>
): string[] {
const lastSeenByPath = new Map<string, number>();
for (const source of sources) {
const cwd = source.cwd?.trim();
if (!cwd) {
continue;
}
if (isPaseoOwnedWorktreePath(cwd)) {
continue;
}
const timestamp = toEpochMs(source.lastActivityAt ?? source.createdAt);
const previous = lastSeenByPath.get(cwd);
if (previous === undefined || timestamp > previous) {
lastSeenByPath.set(cwd, timestamp);
}
}
return Array.from(lastSeenByPath.entries())
.sort((left, right) => {
const timeDiff = right[1] - left[1];
if (timeDiff !== 0) {
return timeDiff;
}
return left[0].localeCompare(right[0]);
})
.map(([cwd]) => cwd);
}
function isPaseoOwnedWorktreePath(cwd: string): boolean {
return PASEO_WORKTREE_PATH_PATTERN.test(cwd.replace(/\\/g, "/"));
}
function toEpochMs(date: Date | null | undefined): number {
if (!(date instanceof Date)) {
return 0;
}
const value = date.getTime();
return Number.isFinite(value) ? value : 0;
}
@@ -0,0 +1,28 @@
import * as Linking from "expo-linking";
import { Platform } from "react-native";
import { getIsTauri } from "@/constants/layout";
interface TauriWindowWithOpener {
__TAURI__?: {
opener?: {
openUrl?: (url: string) => Promise<void>;
};
};
}
export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && getIsTauri()) {
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
if (typeof opener === "function") {
await opener(url);
return;
}
}
window.open(url, "_blank", "noopener,noreferrer");
return;
}
await Linking.openURL(url);
}
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as pythonParser } from "@lezer/python";
import { parser as markdownParser } from "@lezer/markdown";
import { parser as elixirParser } from "lezer-elixir";
import type { Parser } from "@lezer/common";
// Map file extensions to parsers
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
htm: htmlParser,
// Python
py: pythonParser,
// Elixir
ex: elixirParser,
exs: elixirParser,
// Markdown
md: markdownParser,
mdx: markdownParser,
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { upsertTerminalListEntry } from "./terminal-list";
describe("terminal-list", () => {
it("adds a created terminal when the list is empty", () => {
const result = upsertTerminalListEntry({
terminals: [],
terminal: {
id: "term-1",
name: "Terminal 1",
cwd: "/tmp/project",
},
});
expect(result).toEqual([{ id: "term-1", name: "Terminal 1" }]);
});
it("appends a created terminal when the id does not already exist", () => {
const result = upsertTerminalListEntry({
terminals: [{ id: "term-1", name: "Terminal 1" }],
terminal: {
id: "term-2",
name: "Terminal 2",
cwd: "/tmp/project",
},
});
expect(result).toEqual([
{ id: "term-1", name: "Terminal 1" },
{ id: "term-2", name: "Terminal 2" },
]);
});
it("replaces the existing terminal entry when ids match", () => {
const result = upsertTerminalListEntry({
terminals: [
{ id: "term-1", name: "Terminal 1" },
{ id: "term-2", name: "Old Name" },
],
terminal: {
id: "term-2",
name: "Renamed Terminal",
cwd: "/tmp/project",
},
});
expect(result).toEqual([
{ id: "term-1", name: "Terminal 1" },
{ id: "term-2", name: "Renamed Terminal" },
]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type {
CreateTerminalResponse,
ListTerminalsResponse,
} from "@server/shared/messages";
type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number];
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalListEntry {
return {
id: input.terminal.id,
name: input.terminal.name,
};
}
export function upsertTerminalListEntry(input: {
terminals: TerminalListEntry[];
terminal: CreatedTerminal;
}): TerminalListEntry[] {
const createdTerminal = toTerminalListEntry({ terminal: input.terminal });
const existingIndex = input.terminals.findIndex(
(terminal) => terminal.id === createdTerminal.id
);
if (existingIndex < 0) {
return [...input.terminals, createdTerminal];
}
const nextTerminals = [...input.terminals];
nextTerminals[existingIndex] = createdTerminal;
return nextTerminals;
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { buildWorkingDirectorySuggestions } from "./working-directory-suggestions";
describe("buildWorkingDirectorySuggestions", () => {
it("returns de-duplicated recommendations when query is empty", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/projects/paseo"],
serverPaths: ["/Users/me/projects/playground"],
query: "",
});
expect(results).toEqual(["/Users/me/projects/paseo"]);
});
it("prioritizes matching recommended directories before server matches", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
serverPaths: [
"/Users/me/projects/playground",
"/Users/me/projects/paseo",
"/Users/me/projects/planbook",
],
query: "pla",
});
expect(results).toEqual([
"/Users/me/projects/playground",
"/Users/me/projects/planbook",
]);
});
it("puts matching recommended items first when they also match query", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"],
serverPaths: [
"/Users/me/projects/planbook",
"/Users/me/projects/playground",
],
query: "pla",
});
expect(results).toEqual([
"/Users/me/projects/playground",
"/Users/me/projects/planbook",
]);
});
it("treats '~' as an active query and includes server suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo"],
serverPaths: [
"/Users/me/documents",
"/Users/me/projects",
],
query: "~",
});
expect(results).toEqual([
"/Users/me/projects/paseo",
"/Users/me/documents",
"/Users/me/projects",
]);
});
});
@@ -0,0 +1,72 @@
export interface BuildWorkingDirectorySuggestionsInput {
recommendedPaths: string[];
serverPaths: string[];
query: string;
}
export function buildWorkingDirectorySuggestions(
input: BuildWorkingDirectorySuggestionsInput
): string[] {
const rawQuery = input.query.trim();
const recommended = uniquePaths(input.recommendedPaths);
if (!rawQuery) {
return recommended;
}
const normalizedQuery = normalizeQuery(rawQuery);
const shouldFilterByQuery = normalizedQuery.length > 0;
const recommendedMatches = shouldFilterByQuery
? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery))
: recommended;
const seen = new Set(recommendedMatches);
const ordered = [...recommendedMatches];
for (const entry of uniquePaths(input.serverPaths)) {
if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) {
continue;
}
if (seen.has(entry)) {
continue;
}
ordered.push(entry);
seen.add(entry);
}
return ordered;
}
function uniquePaths(paths: string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const pathEntry of paths) {
const trimmed = pathEntry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
ordered.push(trimmed);
}
return ordered;
}
function normalizeQuery(query: string): string {
let normalized = query.trim();
if (!normalized) {
return "";
}
if (normalized.startsWith("~")) {
normalized = normalized.slice(1);
}
normalized = normalized.replace(/^\/+/, "").toLowerCase();
return normalized;
}
function pathMatchesQuery(candidatePath: string, query: string): boolean {
const lowerPath = candidatePath.toLowerCase();
if (lowerPath.includes(query)) {
return true;
}
const segments = lowerPath.split("/");
return (segments[segments.length - 1] ?? "").includes(query);
}