Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 31 additions & 20 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { resolveStableLaneBaseBranch } from "../../../desktop/src/shared/laneBas
import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch";
import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots";
import {
composerFileSearchQuery,
composerTriggerForSelection,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
findConfirmedComposerTokens,
Expand Down Expand Up @@ -2512,6 +2514,10 @@ export const MENTION_MAX_ROWS = 10;
export const MENTION_FILE_ROWS = 5;
const STARTUP_RECONNECT_DELAY_MS = 3_000;

function matchesMentionTarget(target: string, query: string): boolean {
return target.includes(query) || query.startsWith(`${target} `);
Comment on lines +2519 to +2520

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply trailing-prose matching to commit suggestions

In ADE Code, the new keep-alive matching is applied to lane/chat suggestions and PR titles, but the recent-commit filter still checks subject.includes(query). Consequently, after typing an exact commit subject followed by prose, such as @Fix parser please inspect, that commit disappears before Enter can select it, unlike the other newly supported multiword targets. Use this helper for commit subjects as well so selection can preserve the trailing prose.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rank the longest confirmed ADE Code target first

When target labels overlap, this predicate treats every confirmed prefix equally. For example, with a lane named Foo and a commit titled Foo Bar, the query @Foo Bar please keeps both candidates; publishSuggestions at line 7400 always places local lane/chat rows before remote commit/PR rows, so Enter selects Foo and leaves Bar please instead of selecting the exact longer target. Fresh evidence beyond the earlier shared mention-title report is that this separate ADE Code helper also governs commit and PR matches without carrying prefix length into ordering; rank the longest confirmed label before preserving the existing source order.

Useful? React with 👍 / 👎.

}

type MentionRemoteCacheEntry = {
filesByQuery: Map<string, Array<{ path: string }>>;
commits: Array<Record<string, unknown>> | null;
Expand Down Expand Up @@ -7339,7 +7345,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
return;
}
let cancelled = false;
const query = range.query.toLowerCase();
const query = range.query.trim().toLowerCase();
const fileQuery = composerFileSearchQuery(range.query).toLowerCase();
const matchesMentionQuery = (suggestion: MentionSuggestion): boolean => {
if (!query) return true;
const label = suggestion.label.toLowerCase();
return (
matchesMentionTarget(label, query)
|| suggestion.insertText.toLowerCase().includes(query)
|| Boolean(suggestion.detail?.toLowerCase().includes(query))
);
};
const localSuggestions = (): MentionSuggestion[] => [
...lanes.map((lane) => ({
kind: "lane" as const,
Expand All @@ -7353,20 +7369,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
insertText: `@chat:${session.sessionId}`,
detail: session.laneId,
})),
].filter((suggestion) => (
!query
|| suggestion.label.toLowerCase().includes(query)
|| suggestion.insertText.toLowerCase().includes(query)
|| suggestion.detail?.toLowerCase().includes(query)
));
].filter(matchesMentionQuery);
const attachedSuggestions = (): MentionSuggestion[] => selectedMentions
.filter((suggestion) => suggestion.attachment && suggestion.filePath)
.filter((suggestion) => (
!query
|| suggestion.label.toLowerCase().includes(query)
|| suggestion.insertText.toLowerCase().includes(query)
|| suggestion.detail?.toLowerCase().includes(query)
));
.filter(matchesMentionQuery);

const publishSuggestions = (remote: MentionSuggestion[] = []) => {
if (cancelled) return;
Expand All @@ -7390,16 +7396,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
// (shallowest paths first) instead of returning nothing, matching the
// desktop composer's `@` behavior. The cache keys on the query string,
// so "" caches like any typed query.
const filesPromise = cache.filesByQuery.get(query)
? Promise.resolve(cache.filesByQuery.get(query)!)
const filesPromise = cache.filesByQuery.get(fileQuery)
? Promise.resolve(cache.filesByQuery.get(fileQuery)!)
: Promise.resolve(conn.action<Array<{ path: string }>>("file", "quickOpen", {
workspaceId: laneId,
query,
query: fileQuery,
limit: MENTION_FILE_ROWS,
}))
.then((files) => {
const safeFiles = Array.isArray(files) ? files : [];
cache.filesByQuery.set(query, safeFiles);
cache.filesByQuery.set(fileQuery, safeFiles);
return safeFiles;
})
.catch(() => []);
Expand Down Expand Up @@ -7452,7 +7458,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
.filter((pr) => {
const title = String(pr.title ?? "");
const number = String(pr.number ?? pr.prNumber ?? "");
return !query || title.toLowerCase().includes(query) || number.includes(query);
const loweredTitle = title.toLowerCase();
return !query || matchesMentionTarget(loweredTitle, query) || number.includes(query);
})
.slice(0, 5)
.map((pr) => {
Expand Down Expand Up @@ -12331,8 +12338,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
}, [addNotice, chatRowBudget, lanes, models, refreshState, registerOptimisticTerminalSession, selectedMentions, setChatScrollOffset, setDraftChatMode, terminalPaneWidth]);

const insertMention = useCallback((suggestion: MentionSuggestion) => {
const trigger = detectComposerTrigger(prompt, promptCursorRef.current);
if (trigger?.type !== "at") return;
const detectedTrigger = detectComposerTrigger(prompt, promptCursorRef.current);
if (detectedTrigger?.type !== "at") return;
const trigger = composerTriggerForSelection(
detectedTrigger,
suggestion.kind === "file" ? suggestion.filePath ?? suggestion.label : suggestion.label,
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const next = replaceComposerTriggerSpan(prompt, trigger, `${suggestion.insertText} `);
setPromptValue(next.text, next.caret);
setSelectedMentions((prev) => {
Expand Down
33 changes: 28 additions & 5 deletions apps/desktop/src/main/services/files/fileSearchIndexService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,38 @@ function scoreBrowseDepth(normalizedPath: string): number {
return Math.max(1, BROWSE_BASE_SCORE - depth);
}

function scorePathForNeedle(normalized: string, needle: string): number {
if (normalized === needle) return 1000;
if (normalized.endsWith(`/${needle}`) || normalized.endsWith(`\\${needle}`)) return 900;
const idx = normalized.indexOf(needle);
return idx < 0 ? -1 : 600 - idx;
}

function scorePath(pathValue: string, query: string): number {
const normalized = pathValue.toLowerCase();
const needle = query.toLowerCase().trim();
if (!needle) return scoreBrowseDepth(normalized);
if (normalized === needle) return 1000;
if (normalized.endsWith(`/${needle}`) || normalized.endsWith(`\\${needle}`)) return 900;
const idx = normalized.indexOf(needle);
if (idx < 0) return -1;
return 600 - idx;
const directScore = scorePathForNeedle(normalized, needle);
if (directScore >= 0) return directScore;

// Composer @-file queries can contain ordinary prose after an extensionless
// path whose filename or directory contains spaces. A path index cannot
// know that boundary from the string alone, so try progressively shorter
// space-delimited prefixes and keep the longest matching one. Restrict this
// fallback to path-like queries so ordinary multiword quick-open searches
// keep their existing whole-query semantics.
if (!needle.includes("/") && !needle.includes("\\")) return -1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match extensionless files at the workspace root

When a root-level extensionless file is followed by prose, such as @README review this, composerFileSearchQuery leaves the whole query intact and this guard disables the only leading-path fallback because the query contains no slash. The exact README suggestion therefore disappears before it can be selected. Fresh evidence beyond the earlier exact-file report is that the current fallback explicitly excludes root-level names; allow a confirmed leading filename match here without requiring a path separator.

Useful? React with 👍 / 👎.

const words = needle.split(/[ \t]+/);
let best = -1;
for (let end = words.length - 1; end > 0; end -= 1) {
const prefix = words.slice(0, end).join(" ");
const score = scorePathForNeedle(normalized, prefix);
if (score < 0) continue;
// Prefer a longer path prefix when multiple indexed paths share the same
// beginning. The tiny fractional tie-break preserves existing score tiers.
best = Math.max(best, score + Math.min(prefix.length, 999) / 1000);
Comment on lines +119 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep exact full-path matches above prefix fallbacks

When an extensionless path is itself a prefix of another valid path containing spaces—for example files src/foo and src/foo bar with query src/foo bar—the full path receives the normal exact score of 1000, while the shorter file reaches this fallback and receives 1000.007. Sorting therefore places src/foo above the exact requested file, so pressing Enter in quick-open or the composer can attach the wrong file; keep fallback scores below an exact full-query match while using the fractional value only to break ties within the fallback tier.

Useful? React with 👍 / 👎.

}
return best;
}

async function cooperativeYield(): Promise<void> {
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/main/services/files/fileService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,29 @@ describe("fileService", () => {
}
});

it("matches an extensionless path with spaces before trailing prose", async () => {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-spaced-path-"));
const { execSync } = await import("node:child_process");
execSync("git init", { cwd: rootPath, stdio: "ignore" });
const laneService = createLaneServiceStub(rootPath);
const service = createFileService({ laneService });

try {
fs.mkdirSync(path.join(rootPath, "src"), { recursive: true });
fs.writeFileSync(path.join(rootPath, "src", "my folder"), "extensionless path\n", "utf8");

const quickOpen = await service.quickOpen({
workspaceId: "workspace-1",
query: "src/my folder about this",
includeIgnored: true,
});

expect(quickOpen.map((item) => item.path)).toContain("src/my folder");
} finally {
removeTestTree(rootPath);
}
});

it("warms the quick open index for subsequent lookups", async () => {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-warm-search-"));
const { execSync } = await import("node:child_process");
Expand Down
135 changes: 135 additions & 0 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,141 @@ describe("AgentChatComposer", () => {
expect(await screen.findByText("App.tsx")).toBeTruthy();
});

it("keeps an exact file match available after trailing prose", async () => {
const onSearchAttachments = vi.fn().mockResolvedValue([{ path: "src/foo.ts", type: "file" }]);

renderComposer({
turnActive: false,
draft: "",
sessionId: "session-1",
onSearchAttachments,
});

const draft = "ask @src/foo.ts about this";
fireEvent.change(screen.getByRole("textbox"), {
target: { value: draft, selectionStart: draft.length },
});

await waitFor(() => expect(onSearchAttachments).toHaveBeenCalledWith("src/foo.ts"));
expect(await screen.findByText("foo.ts")).toBeTruthy();
});

it("keeps an extensionless spaced file path intact before trailing prose", async () => {
const onSearchAttachments = vi.fn().mockResolvedValue([{ path: "src/my folder", type: "file" }]);

renderComposer({
turnActive: false,
draft: "",
sessionId: "session-1",
onSearchAttachments,
});

const draft = "ask @src/my folder about this";
fireEvent.change(screen.getByRole("textbox"), {
target: { value: draft, selectionStart: draft.length },
});

await waitFor(() => expect(onSearchAttachments).toHaveBeenCalledWith("src/my folder about this"));
expect(await screen.findByText("my folder")).toBeTruthy();
});

it("keeps spaced chat mentions searchable and displays the chat title in the chip", async () => {
const onSearchMentions = vi.fn().mockResolvedValue([{
kind: "chat" as const,
id: "chat-1",
title: "a b c",
subtitle: "Primary · codex",
}]);
const props = buildComposerProps({
turnActive: false,
draft: "",
onSearchMentions,
});
const view = render(<AgentChatComposer {...props} />);
const textbox = screen.getByRole("textbox");

fireEvent.change(textbox, {
target: { value: "@a b c", selectionStart: 6 },
});
view.rerender(<AgentChatComposer {...props} draft="@a b c" />);

await waitFor(() => expect(onSearchMentions).toHaveBeenCalledWith("a b c"));
fireEvent.click(await screen.findByText("a b c"));

expect(props.onDraftChange).toHaveBeenLastCalledWith("@chat:chat-1 ");
view.rerender(<AgentChatComposer {...props} draft="@chat:chat-1 " />);

const chip = await screen.findByText("a b c");
expect(chip.textContent).toBe("a b c");
expect(chip.closest("[aria-hidden]")).not.toBeNull();
expect(view.container.querySelector("[data-composer-mention-layout]")?.textContent).toBe("@chat:chat-1");
expect(view.container.querySelector("[data-composer-mention-display]")?.textContent).toBe("a b c");
});

it("restores persisted mention titles after a plain composer remount", () => {
const props = buildComposerProps({
turnActive: false,
draft: "@chat:chat-1 ",
mentionLabels: { "@chat:chat-1": "a b c" },
});
const first = render(<AgentChatComposer {...props} />);
expect(first.container.querySelector("[data-composer-mention-display]")?.textContent).toBe("a b c");

first.unmount();
const second = render(<AgentChatComposer {...props} />);
expect(second.container.querySelector("[data-composer-mention-display]")?.textContent).toBe("a b c");
});

it("restores persisted mention titles after a rich composer remount", () => {
const iosContext = {
kind: "ios_element" as const,
id: "ios-1",
componentId: "PrimaryButton",
sourceFile: null,
sourceLine: null,
frame: null,
metadata: { label: "Primary" },
selectedAt: "2026-05-07T00:00:00.000Z",
};
const props = buildComposerProps({
turnActive: false,
draft: "@chat:chat-1 ",
mentionLabels: { "@chat:chat-1": "a b c" },
iosElementContextItems: [iosContext],
});
const first = render(<AgentChatComposer {...props} />);
expect(first.container.querySelector("[data-composer-chip='mention']")?.textContent).toBe("a b c");

first.unmount();
const second = render(<AgentChatComposer {...props} />);
expect(second.container.querySelector("[data-composer-chip='mention']")?.textContent).toBe("a b c");
});

it("does not consume prose after a matching spaced chat mention", async () => {
const onSearchMentions = vi.fn().mockResolvedValue([{
kind: "chat" as const,
id: "chat-1",
title: "a b c",
}]);
const props = buildComposerProps({
turnActive: false,
draft: "",
onSearchMentions,
});
const view = render(<AgentChatComposer {...props} />);
const textbox = screen.getByRole("textbox");
const draft = "ask @a b c about this";

fireEvent.change(textbox, {
target: { value: draft, selectionStart: draft.length },
});
view.rerender(<AgentChatComposer {...props} draft={draft} />);

fireEvent.click(await screen.findByText("a b c"));

expect(props.onDraftChange).toHaveBeenLastCalledWith("ask @chat:chat-1 about this");
});

it("uses lane attachment search for at-command suggestions before a session exists", async () => {
const onSearchAttachments = vi.fn().mockResolvedValue([{ path: "docs/README.md", type: "file" }]);

Expand Down
Loading
Loading