Restore fuzzy project folder search and desktop browsing (#1968)

* fix(projects): restore folder search and browsing

* fix(projects): preserve fuzzy search edge cases

* test(projects): move picker cleanup into fixture

* fix(projects): preserve absolute path descendants

* test(server): compare canonical search paths

* fix(projects): anchor root path suggestions

* fix(projects): anchor Windows root suggestions

* fix(projects): anchor directory search paths

* test(server): canonicalize directory search root

* fix(app): anchor rooted basename suggestions

* refactor(search): unify directory suggestions

Use one parameterized daemon search engine for project paths and workspace entries. Keep local recommendations as a small client overlay and remove unnecessary response metadata.

* fix(search): preserve filesystem root semantics

Accept absolute queries through configured root aliases while keeping traversal and output canonical. Keep blank suffix browsing matcher-independent and avoid metadata-keyed listing caches where Windows cannot invalidate them reliably.
This commit is contained in:
Mohamed Boudra
2026-07-10 11:52:55 +02:00
committed by GitHub
parent 1f0285c06b
commit 61a9117a6b
30 changed files with 1552 additions and 1465 deletions

View File

@@ -239,8 +239,13 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: playwright (shard ${{ matrix.shard }}/4)
include:
- { label: "shard 1/4", shard: 1, desktop: false }
- { label: "shard 2/4", shard: 2, desktop: false }
- { label: "shard 3/4", shard: 3, desktop: false }
- { label: "shard 4/4", shard: 4, desktop: false }
- { label: "desktop overlay", shard: "desktop", desktop: true }
name: playwright (${{ matrix.label }})
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -276,13 +281,19 @@ jobs:
run: npm run build:server
- name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }}
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: ${{ !matrix.desktop }}
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }}
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()

View File

@@ -185,6 +185,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
correlated daemon response. The legacy `directories` response field remains a projection of the
typed `entries` list.
**Binary frames (terminal stream protocol):**
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
@@ -74,6 +75,26 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
}
test.describe("Project picker search", () => {
test("opens a project from a fuzzy directory-name search", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPickerFixture.fuzzyQuery);
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
await expect(suggestion).toBeVisible({ timeout: 30_000 });
await suggestion.click();
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
});
test("shows a loading state after typing while directory suggestions are pending", async ({
page,
}) => {

View File

@@ -1,16 +1,30 @@
import { test as base, expect, type Page } from "@playwright/test";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import {
createProjectPickerFixture,
removeProjectPickerFixture,
type ProjectPickerFixture,
} from "./helpers/project-picker-fixture";
import { connectSeedClient } from "./helpers/seed-client";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
rememberProjectId: (projectId: string) => void;
}
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
// reliably for every test that uses this `test` object.
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
const test = base.extend<{
paseoE2ESetup: void;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -102,6 +116,30 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
{ auto: true },
],
projectPickerFixture: async ({}, provide) => {
const resource = await createProjectPickerFixture();
const { fixture } = resource;
let projectId: string | null = null;
try {
await provide({
...fixture,
rememberProjectId: (openedProjectId) => {
projectId = openedProjectId;
},
});
} finally {
try {
const client = await connectSeedClient();
try {
await removeProjectPickerFixture(client, fixture, projectId);
} finally {
await client.close();
}
} finally {
await resource.removeDirectory();
}
}
},
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);

View File

@@ -71,6 +71,7 @@ export interface DesktopBridgeConfig {
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
dialogOpenResult?: string | string[] | null;
editorTargets?: DesktopEditorTargetConfig[];
editorRecordPath?: string;
}
@@ -96,6 +97,7 @@ export interface ConfirmDialogCall {
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
}
}
@@ -157,6 +159,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
@@ -249,6 +252,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
};
return cfg.confirmShouldAccept ?? false;
},
open: async (options?: Record<string, unknown>) => {
window.__capturedDialogOpenCalls.push(options);
return cfg.dialogOpenResult ?? null;
},
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
@@ -263,10 +270,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
};
}
window.__capturedDialogOpenCalls = [];
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
}, config);
}
export async function waitForDirectoryDialog(
page: Page,
): Promise<Record<string, unknown> | undefined> {
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);

View File

@@ -0,0 +1,48 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type { SeedDaemonClient } from "./seed-client";
export interface ProjectPickerFixture {
projectPath: string;
projectName: string;
fuzzyQuery: string;
}
interface ProjectPickerFixtureResource {
fixture: ProjectPickerFixture;
removeDirectory: () => Promise<void>;
}
export async function createProjectPickerFixture(): Promise<ProjectPickerFixtureResource> {
const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-"));
const nonce = randomUUID().replaceAll("-", "").slice(0, 8);
const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`);
await mkdir(projectPath, { recursive: true });
return {
fixture: {
projectPath,
projectName: path.basename(projectPath),
fuzzyQuery: `psodfzt${nonce}`,
},
removeDirectory: () => rm(root, { recursive: true, force: true }),
};
}
export async function removeProjectPickerFixture(
client: SeedDaemonClient,
fixture: ProjectPickerFixture,
knownProjectId: string | null = null,
): Promise<void> {
let projectId = knownProjectId;
if (!projectId) {
const lookup = await client.addProject(fixture.projectPath);
projectId = lookup.project?.projectId ?? null;
if (!projectId) {
throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup");
}
}
await client.removeProject(projectId);
}

View File

@@ -0,0 +1,13 @@
import { expect, type Page } from "@playwright/test";
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectName })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
}

View File

@@ -0,0 +1,60 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { getServerId } from "./helpers/server-id";
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
test("Browse opens the folder selected by the desktop dialog", async ({
page,
projectPickerFixture,
}) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
manageBuiltInDaemon: false,
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await page.getByTestId("sidebar-add-project").click();
const browse = page.getByRole("button", { name: "Browse…" });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
});
test("Browse owns Enter without opening the active typed path", async ({
page,
projectPickerFixture,
}) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
manageBuiltInDaemon: false,
dialogOpenResult: null,
});
await gotoAppShell(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPickerFixture.projectPath);
const browse = page.getByRole("button", { name: "Browse…" });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.press("Enter");
const dialogOptions = await waitForDirectoryDialog(page);
expect(dialogOptions).toEqual({
directory: true,
multiple: false,
});
await expect(input).toBeVisible();
await expect(
page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectPickerFixture.projectName }),
).toHaveCount(0);
});

View File

@@ -22,6 +22,7 @@
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",

View File

@@ -0,0 +1,45 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { pickDirectory } from "@/desktop/pick-directory";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton({
serverId,
disabled,
onSelect,
onError,
}: ProjectPickerBrowseButtonProps) {
const { t } = useTranslation();
const isLocalDaemon = useIsLocalDaemon(serverId);
const handlePress = useCallback(() => {
void (async () => {
try {
const path = await pickDirectory();
if (path) {
onSelect(path);
}
} catch {
onError();
}
})();
}, [onError, onSelect]);
if (!isLocalDaemon) {
return null;
}
return (
<Button
variant="outline"
size="sm"
leftIcon={FolderOpen}
disabled={disabled}
onPress={handlePress}
>
{t("projectPicker.browse")}
</Button>
);
}

View File

@@ -0,0 +1,5 @@
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
return null;
}

View File

@@ -0,0 +1,6 @@
export interface ProjectPickerBrowseButtonProps {
serverId: string;
disabled: boolean;
onSelect: (path: string) => void;
onError: () => void;
}

View File

@@ -21,6 +21,7 @@ import { useProjectPickerStore } from "@/stores/project-picker-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { shortenPath } from "@/utils/shorten-path";
import { isNative } from "@/constants/platform";
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
interface PathRowProps {
@@ -155,31 +156,36 @@ export function ProjectPickerModal() {
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
queryFn: async () => {
if (!client) return [];
if (!client) {
return { query: debouncedQuery, paths: [] };
}
const result = await client.getDirectorySuggestions({
query: debouncedQuery,
includeDirectories: true,
includeFiles: false,
limit: 30,
});
return (
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
);
return {
query: debouncedQuery,
paths:
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
[],
};
},
enabled: Boolean(client) && isConnected && open,
staleTime: 15_000,
retry: false,
});
const options = useMemo(
() =>
buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[directorySuggestionsQuery.data, query, recommendedPaths],
);
const options = useMemo(() => {
const currentSuggestions =
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
return buildProjectPickerOptions({
recommendedPaths,
serverPaths: currentSuggestions?.paths ?? [],
query,
});
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
const hasQuery = query.trim().length > 0;
const isSearching =
hasQuery &&
@@ -213,6 +219,8 @@ export function ProjectPickerModal() {
}
setOpenErrorReason(getOpenProjectFailureReason(result));
} catch {
setOpenErrorReason("open_failed");
} finally {
setIsSubmitting(false);
}
@@ -232,6 +240,10 @@ export function ProjectPickerModal() {
setOpenErrorReason(null);
}, []);
const handleBrowseError = useCallback(() => {
setOpenErrorReason("open_failed");
}, []);
useEffect(() => {
if (open) {
setQuery("");
@@ -262,7 +274,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
@@ -270,12 +282,6 @@ export function ProjectPickerModal() {
return;
}
if (key === "Enter") {
event.preventDefault();
submitActiveOption();
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (options.length === 0) return;
event.preventDefault();
@@ -291,7 +297,7 @@ export function ProjectPickerModal() {
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [close, open, options.length, submitActiveOption]);
}, [close, open, options.length]);
const panelStyle = useMemo(
() => [
@@ -343,6 +349,12 @@ export function ProjectPickerModal() {
returnKeyType="go"
onSubmitEditing={submitActiveOption}
/>
<ProjectPickerBrowseButton
serverId={serverId}
disabled={isSubmitting}
onSelect={handleSelectPath}
onError={handleBrowseError}
/>
</View>
<ProjectPickerResults
@@ -383,11 +395,15 @@ const styles = StyleSheet.create((theme) => ({
...theme.shadow.lg,
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
},
input: {
flex: 1,
fontSize: theme.fontSize.lg,
paddingVertical: theme.spacing[1],
outlineStyle: "none",

View File

@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
it("puts an absolute path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/repo/api"],
serverPaths: [],
serverPaths: ["/repo/api"],
query: "/repo",
});
@@ -48,7 +48,7 @@ describe("buildProjectPickerOptions", () => {
it("puts a home-relative path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/Users/mo/src/api"],
serverPaths: [],
serverPaths: ["/Users/mo/src/api"],
query: "~/src",
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "./host";
import { pickDirectory } from "./pick-directory";
describe("pickDirectory", () => {
it("opens a single-directory picker and returns the selection", async () => {
const recordedOptions: DesktopDialogOpenOptions[] = [];
const dialog: DesktopDialogBridge = {
open: async (options) => {
if (options) {
recordedOptions.push(options);
}
return "/repo/project";
},
};
await expect(pickDirectory(dialog)).resolves.toBe("/repo/project");
expect(recordedOptions).toEqual([
{
directory: true,
multiple: false,
},
]);
});
it("returns null when the picker is cancelled", async () => {
const dialog: DesktopDialogBridge = {
open: async () => null,
};
await expect(pickDirectory(dialog)).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
import { getDesktopHost, type DesktopDialogBridge } from "./host";
export async function pickDirectory(
dialog: DesktopDialogBridge | null = getDesktopHost()?.dialog ?? null,
): Promise<string | null> {
const open = dialog?.open;
if (typeof open !== "function") {
throw new Error("Desktop dialog open() is unavailable in this environment.");
}
const selection = await open({
directory: true,
multiple: false,
});
if (selection === null) {
return null;
}
if (typeof selection === "string") {
return selection;
}
throw new Error("Unexpected directory picker response.");
}

View File

@@ -1094,6 +1094,7 @@ export const ar: TranslationResources = {
},
projectPicker: {
placeholder: "اكتب مسار الدليل...",
browse: "استعراض…",
opening: "افتتاح المشروع...",
searching: "جارٍ البحث...",
empty: "ابدأ بكتابة المسار",

View File

@@ -1101,6 +1101,7 @@ export const en = {
},
projectPicker: {
placeholder: "Type a directory path...",
browse: "Browse…",
opening: "Opening project...",
searching: "Searching...",
empty: "Start typing a path",

View File

@@ -1130,6 +1130,7 @@ export const es: TranslationResources = {
},
projectPicker: {
placeholder: "Escriba una ruta de directorio...",
browse: "Explorar…",
opening: "Proyecto de apertura...",
searching: "Buscando...",
empty: "Comience a escribir una ruta",

View File

@@ -1132,6 +1132,7 @@ export const fr: TranslationResources = {
},
projectPicker: {
placeholder: "Tapez un chemin de répertoire...",
browse: "Parcourir…",
opening: "Projet d'ouverture...",
searching: "Recherche en cours...",
empty: "Commencez à taper un chemin",

View File

@@ -1108,6 +1108,7 @@ export const ja: TranslationResources = {
},
projectPicker: {
placeholder: "ディレクトリパスを入力...",
browse: "参照…",
opening: "プロジェクトを開いています...",
searching: "検索中...",
empty: "パスを入力してください",

View File

@@ -1116,6 +1116,7 @@ export const ptBR: TranslationResources = {
},
projectPicker: {
placeholder: "Digite um caminho de diretório...",
browse: "Procurar…",
opening: "Abrindo projeto...",
searching: "Buscando...",
empty: "Comece digitando um caminho",

View File

@@ -1120,6 +1120,7 @@ export const ru: TranslationResources = {
},
projectPicker: {
placeholder: "Введите путь к каталогу...",
browse: "Обзор…",
opening: "Открытие проекта...",
searching: "Идет поиск...",
empty: "Начните вводить путь",

View File

@@ -1079,6 +1079,7 @@ export const zhCN: TranslationResources = {
},
projectPicker: {
placeholder: "输入目录路径...",
browse: "浏览…",
opening: "正在打开 project...",
searching: "正在搜索...",
empty: "开始输入路径",

View File

@@ -12,31 +12,40 @@ describe("buildWorkingDirectorySuggestions", () => {
expect(results).toEqual(["/Users/me/projects/paseo"]);
});
it("prioritizes matching recommended directories before server matches", () => {
it("keeps fuzzy recommendation matches before de-duplicated daemon suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
serverPaths: [
"/Users/me/projects/playground",
"/Users/me/projects/paseo",
"/Users/me/projects/planbook",
recommendedPaths: ["/Users/me/projects/paseo-desktop", "/Users/me/documents"],
serverPaths: ["/Users/me/projects/paseo-plan", "/Users/me/projects/paseo-desktop"],
query: "pso",
});
expect(results).toEqual(["/Users/me/projects/paseo-desktop", "/Users/me/projects/paseo-plan"]);
});
it("does not reinterpret daemon-ranked suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: [],
serverPaths: ["/Users/me/projects/paseo-desktop"],
query: "a-query-ranked-by-the-daemon",
});
expect(results).toEqual(["/Users/me/projects/paseo-desktop"]);
});
it("leaves path-query semantics to the daemon", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: [
"/Users/me/archive/projects/paseo-desktop",
"/Users/me/projects/paseo-desktop",
],
query: "pla",
serverPaths: [],
query: "~/projects/pso",
});
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
expect(results).toEqual([]);
});
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", () => {
it("treats '~' as an active query and includes daemon suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo"],
serverPaths: ["/Users/me/documents", "/Users/me/projects"],

View File

@@ -1,3 +1,5 @@
import { scoreMatch } from "./score-match";
export interface BuildWorkingDirectorySuggestionsInput {
recommendedPaths: string[];
serverPaths: string[];
@@ -7,40 +9,27 @@ export interface BuildWorkingDirectorySuggestionsInput {
export function buildWorkingDirectorySuggestions(
input: BuildWorkingDirectorySuggestionsInput,
): string[] {
const rawQuery = input.query.trim();
const query = input.query.trim();
const recommended = uniquePaths(input.recommendedPaths);
if (!rawQuery) {
if (!query) {
return recommended;
}
const normalizedQuery = normalizeQuery(rawQuery);
const shouldFilterByQuery = normalizedQuery.length > 0;
const matchingRecommended = recommended.filter((path) =>
recommendedPathMatchesQuery(path, query),
);
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;
// The request owner correlates these results with the current query. The
// daemon owns filesystem query parsing, filtering, and ranking; doing it
// again here creates a second search implementation that can disagree.
return uniquePaths([...matchingRecommended, ...input.serverPaths]);
}
function uniquePaths(paths: string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const pathEntry of paths) {
const trimmed = pathEntry.trim();
for (const path of paths) {
const trimmed = path.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
@@ -50,23 +39,20 @@ function uniquePaths(paths: string[]): string[] {
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)) {
function recommendedPathMatchesQuery(path: string, query: string): boolean {
const candidate = normalizePath(path);
const normalizedQuery = normalizePath(query);
if (["~", "~/"].includes(normalizedQuery)) {
return true;
}
const segments = lowerPath.split("/");
return (segments[segments.length - 1] ?? "").includes(query);
if (normalizedQuery.includes("/") || normalizedQuery.startsWith("~")) {
return false;
}
const basename = candidate.split("/").at(-1) ?? "";
return candidate.includes(normalizedQuery) || scoreMatch(normalizedQuery, basename) !== null;
}
function normalizePath(value: string): string {
return value.trim().replace(/\\/g, "/").toLowerCase();
}

View File

@@ -1,5 +1,5 @@
import { test, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir, homedir } from "node:os";
import path from "node:path";
@@ -831,6 +831,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as
test("returns home-scoped directory suggestions", async () => {
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
const rootBrowseDir = mkdtempSync(path.join(homedir(), "000-paseo-root-browse-"));
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
try {
@@ -842,6 +843,17 @@ test("returns home-scoped directory suggestions", async () => {
expect(insideResult.error).toBeNull();
expect(insideResult.directories).toContain(insideHomeDir);
const rootBrowseResult = await ctx.client.getDirectorySuggestions({
query: "~",
limit: 100,
});
expect(rootBrowseResult.error).toBeNull();
expect(rootBrowseResult.directories).toContain(rootBrowseDir);
const blankResult = await ctx.client.getDirectorySuggestions({ query: "", limit: 100 });
expect(blankResult.error).toBeNull();
expect(blankResult.entries).toEqual([]);
const outsideQuery = path.basename(outsideHomeDir);
const outsideResult = await ctx.client.getDirectorySuggestions({
query: outsideQuery,
@@ -851,10 +863,35 @@ test("returns home-scoped directory suggestions", async () => {
expect(outsideResult.directories).not.toContain(outsideHomeDir);
} finally {
rmSync(insideHomeDir, { recursive: true, force: true });
rmSync(rootBrowseDir, { recursive: true, force: true });
rmSync(outsideHomeDir, { recursive: true, force: true });
}
}, 30000);
test("returns typed relative suggestions within a requested directory", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-workspace-suggestion-"));
const target = path.join(cwd, "src", "components", "message-renderer.tsx");
try {
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, "");
const result = await ctx.client.getDirectorySuggestions({
cwd,
query: "msgrndr",
includeFiles: true,
includeDirectories: false,
limit: 20,
});
expect(result.error).toBeNull();
expect(result.directories).toEqual([]);
expect(result.entries).toEqual([{ path: "src/components/message-renderer.tsx", kind: "file" }]);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
test("receives server_info on websocket connect", async () => {
const client = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,

View File

@@ -174,7 +174,7 @@ import {
type AgentUpdatesService,
} from "./session/agent-updates/agent-updates-service.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
import { searchDirectoryEntries } from "../utils/directory-suggestions.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
@@ -223,6 +223,14 @@ import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dis
// the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
".agents",
".claude",
".codex",
".github",
".paseo",
".vscode",
];
function errorToFriendlyMessage(error: unknown): string {
if (error instanceof Error) return error.message;
@@ -3049,22 +3057,23 @@ export class Session {
try {
const workspaceCwd = cwd?.trim();
const entries = workspaceCwd
? await searchWorkspaceEntries({
cwd: expandTilde(workspaceCwd),
query,
limit,
includeFiles,
includeDirectories,
matchMode,
})
: (
await searchHomeDirectories({
homeDir: process.env.HOME ?? homedir(),
query,
limit,
})
).map((path) => ({ path, kind: "directory" as const }));
const searchesWorkspace = Boolean(workspaceCwd);
const entries = await searchDirectoryEntries({
root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()),
query,
pathFormat: searchesWorkspace ? "relative" : "absolute",
pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted",
blankQueryBehavior: searchesWorkspace ? "children" : "none",
rootAliases: searchesWorkspace ? [] : ["~"],
traversableHiddenDirectoryNames: searchesWorkspace
? WORKSPACE_SEARCH_HIDDEN_DIRECTORIES
: [],
confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000,
includeFiles,
includeDirectories,
matchMode,
limit,
});
const directories = entries
.filter((entry) => entry.kind === "directory")
.map((entry) => entry.path);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff