diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index 5fc5055c0..d356c14f3 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -73,7 +73,8 @@ vi.mock("node:fs", async () => { }; }); -import { AdeCodeApp, BACKGROUND_REFRESH_DEBOUNCE_MS, isLaneWorktreeAvailable, LANE_STATUS_REFRESH_MS, MENTION_REMOTE_DEBOUNCE_MS, shouldHydrateRefreshHistory } from "../app"; +import { AdeCodeApp, BACKGROUND_REFRESH_DEBOUNCE_MS, isLaneWorktreeAvailable, LANE_STATUS_REFRESH_MS, MENTION_REMOTE_DEBOUNCE_MS, rankMentionSuggestions, shouldHydrateRefreshHistory } from "../app"; +import type { MentionSuggestion } from "../types"; const reactActGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; let previousReactActEnvironment: boolean | undefined; @@ -268,6 +269,17 @@ describe("AdeCodeApp polling", () => { vi.clearAllMocks(); }); + it("ranks longer confirmed mention prefixes before shorter matches", () => { + const suggestions: MentionSuggestion[] = [ + { kind: "lane", label: "Foo", insertText: "@lane:foo", detail: "foo" }, + { kind: "commit", label: "Foo Bar", insertText: "@commit:abc1234", detail: "abc1234" }, + { kind: "pr", label: "Foo Bar", insertText: "@pr:42", detail: "#42" }, + ]; + + expect(rankMentionSuggestions(suggestions, "foo bar please").map((suggestion) => suggestion.insertText)) + .toEqual(["@commit:abc1234", "@pr:42", "@lane:foo"]); + }); + it("polls summary refreshes without hydrating chat history", async () => { const instance = await renderApp(); @@ -561,6 +573,104 @@ describe("AdeCodeApp polling", () => { await unmountApp(instance); }); + + it("closes a confirmed spaced-file mention while typing trailing prose", async () => { + const actionMock = vi.fn(async (domain: string, action: string) => { + if (domain === "file" && action === "quickOpen") return [{ path: "src/my folder" }]; + return []; + }); + connection.action = actionMock as unknown as AdeCodeConnection["action"]; + + const instance = await renderApp(); + + await act(async () => { + instance.stdin.write("@src/my"); + }); + await flushInkFrame(); + await act(async () => { + await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS); + }); + await flushAsyncEffects(); + + expect(actionMock.mock.calls.filter(([domain, action]) => domain === "file" && action === "quickOpen")) + .toHaveLength(1); + + await act(async () => { + instance.stdin.write("\t"); + await flushAsyncEffects(); + instance.stdin.write(" review this"); + }); + await flushInkFrame(); + await act(async () => { + await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS); + }); + await flushAsyncEffects(); + + expect(actionMock.mock.calls.filter(([domain, action]) => domain === "file" && action === "quickOpen")) + .toHaveLength(1); + await unmountApp(instance); + }); + + it("closes a commit mention trigger after trailing prose", async () => { + const actionMock = vi.fn(async (domain: string, action: string) => { + if (domain === "git" && action === "listRecentCommits") { + return [{ shortSha: "abc1234", subject: "Fix parser" }]; + } + return []; + }); + connection.action = actionMock as unknown as AdeCodeConnection["action"]; + mocks.listLanes.mockResolvedValue([lane({ name: "Fix" })]); + + const instance = await renderApp(); + + await act(async () => { + instance.stdin.write("@Fix parser please inspect"); + }); + await flushInkFrame(); + await act(async () => { + await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS); + }); + await flushAsyncEffects(); + + await act(async () => { + instance.stdin.write("\t"); + }); + await flushInkFrame(); + + expect(stripAnsi(instance.frames.join("\n"))).toContain("@commit:abc1234 please inspect"); + expect(stripAnsi(instance.lastFrame() ?? "")).not.toContain("References ·"); + await unmountApp(instance); + }); + + it("closes a PR mention trigger after trailing prose", async () => { + const actionMock = vi.fn(async (domain: string, action: string) => { + if (domain === "pr" && action === "listAll") { + return [{ id: "42", number: 42, title: "Fix parser" }]; + } + return []; + }); + connection.action = actionMock as unknown as AdeCodeConnection["action"]; + + const instance = await renderApp(); + + await act(async () => { + instance.stdin.write("@Fix parser please inspect"); + }); + await flushInkFrame(); + await act(async () => { + await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS); + }); + await flushAsyncEffects(); + + await act(async () => { + instance.stdin.write("\t"); + }); + await flushInkFrame(); + + expect(stripAnsi(instance.frames.join("\n"))).toContain("@pr:42 please inspect"); + expect(stripAnsi(instance.lastFrame() ?? "")).not.toContain("References ·"); + await unmountApp(instance); + }); }); describe("TUI product analytics policy", () => { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 7328fca8a..f5a662de0 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -15,12 +15,16 @@ 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, + composerTriggerHasConfirmedPrefix, composerTriggerSpansWholeDraft, detectComposerTrigger, findConfirmedComposerTokens, replaceComposerTriggerSpan, type ComposerTokenRange, } from "../../../desktop/src/shared/composerTriggers"; +import { isChatMentionTokenBody } from "../../../desktop/src/shared/chatMentions"; import { findSmartLinks } from "../../../desktop/src/shared/smartLinks"; import type { AgentChatClaudePlugin, @@ -2512,6 +2516,33 @@ 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} `); +} + +/** + * Prefer the longest label that is a confirmed prefix of the query. This lets + * `@Foo Bar please` select `Foo Bar` before a shorter `Foo` lane while keeping + * the existing source order for unrelated or equally long matches. + */ +export function rankMentionSuggestions( + suggestions: MentionSuggestion[], + query: string, +): MentionSuggestion[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return suggestions; + + return suggestions + .map((suggestion, index) => { + const label = suggestion.label.trim().toLowerCase(); + const isConfirmedPrefix = label.length > 0 + && (normalizedQuery === label || normalizedQuery.startsWith(`${label} `)); + return { suggestion, index, prefixLength: isConfirmedPrefix ? label.length : 0 }; + }) + .sort((left, right) => right.prefixLength - left.prefixLength || left.index - right.index) + .map(({ suggestion }) => suggestion); +} + type MentionRemoteCacheEntry = { filesByQuery: Map>; commits: Array> | null; @@ -4964,9 +4995,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setSelectedDrawerChatAction(action); applyDrawerChatSelection({ session: session ?? null, action }); }, [applyDrawerChatSelection, openDrawerSessions, selectActiveLaneId]); - const activeComposerTrigger = useMemo(() => ( - activePane === "chat" ? detectComposerTrigger(prompt, promptCursor) : null - ), [activePane, prompt, promptCursor]); + const activeComposerTrigger = useMemo(() => { + if (activePane !== "chat") return null; + const trigger = detectComposerTrigger(prompt, promptCursor); + if (!trigger) return null; + const confirmedFile = (body: string) => selectedMentions.some( + (mention) => mention.kind === "file" && mention.insertText === `@${body}`, + ); + const confirmedMention = (body: string) => isChatMentionTokenBody(body) + || selectedMentions.some( + (mention) => mention.kind !== "file" && mention.insertText === `@${body}`, + ); + return composerTriggerHasConfirmedPrefix(prompt, trigger, { + isFile: confirmedFile, + isMention: confirmedMention, + }) ? null : trigger; + }, [activePane, prompt, promptCursor, selectedMentions]); const activeMentionRange = useMemo(() => ( activeComposerTrigger?.type === "at" ? { start: activeComposerTrigger.start, query: activeComposerTrigger.query } @@ -7339,7 +7383,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, @@ -7353,30 +7407,25 @@ 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; const local = localSuggestions(); // On a bare `@` every lane and chat matches, so without a reservation the - // row cap would drop the whole browse list of files. Only browse mode - // trims locals; typed queries keep their existing ordering untouched. + // row cap would drop the whole browse list of files. Typed queries keep + // all local candidates long enough for prefix ranking to choose the most + // specific target before the row cap is applied. const fileRows = query ? 0 : Math.min(remote.filter((s) => s.kind === "file").length, MENTION_FILE_ROWS); const localBudget = Math.max(0, MENTION_MAX_ROWS - fileRows); - const next = [...local.slice(0, localBudget), ...remote, ...attachedSuggestions()].slice(0, MENTION_MAX_ROWS); + const localCandidates = query ? local : local.slice(0, localBudget); + const next = rankMentionSuggestions( + [...localCandidates, ...remote, ...attachedSuggestions()], + query, + ).slice(0, MENTION_MAX_ROWS); setMentionSuggestions(next); setMentionIndex((index) => Math.min(index, Math.max(0, next.length - 1))); }; @@ -7390,16 +7439,17 @@ 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>("file", "quickOpen", { workspaceId: laneId, - query, + query: fileQuery, limit: MENTION_FILE_ROWS, + allowComposerPrefixFallback: true, })) .then((files) => { const safeFiles = Array.isArray(files) ? files : []; - cache.filesByQuery.set(query, safeFiles); + cache.filesByQuery.set(fileQuery, safeFiles); return safeFiles; }) .catch(() => []); @@ -7436,7 +7486,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, .filter((commit) => { const subject = String(commit.subject ?? commit.message ?? ""); const sha = String(commit.shortSha ?? commit.sha ?? ""); - return !query || subject.toLowerCase().includes(query) || sha.toLowerCase().includes(query); + return !query || matchesMentionTarget(subject.toLowerCase(), query) || sha.toLowerCase().includes(query); }) .slice(0, 5) .map((commit) => { @@ -7452,7 +7502,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) => { @@ -12331,8 +12382,13 @@ 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, + suggestion.kind === "file" ? "file" : "mention", + ); const next = replaceComposerTriggerSpan(prompt, trigger, `${suggestion.insertText} `); setPromptValue(next.text, next.caret); setSelectedMentions((prev) => { diff --git a/apps/desktop/src/main/services/files/fileSearchIndexService.ts b/apps/desktop/src/main/services/files/fileSearchIndexService.ts index 5591d10a1..f7464ee8c 100644 --- a/apps/desktop/src/main/services/files/fileSearchIndexService.ts +++ b/apps/desktop/src/main/services/files/fileSearchIndexService.ts @@ -85,15 +85,42 @@ function scoreBrowseDepth(normalizedPath: string): number { return Math.max(1, BROWSE_BASE_SCORE - depth); } -function scorePath(pathValue: string, query: string): number { - const normalized = pathValue.toLowerCase(); - const needle = query.toLowerCase().trim(); - if (!needle) return scoreBrowseDepth(normalized); +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); - if (idx < 0) return -1; - return 600 - idx; + return idx < 0 ? -1 : 600 - idx; +} + +function scorePath(pathValue: string, query: string, allowComposerPrefixFallback: boolean): number { + const normalized = pathValue.toLowerCase(); + const needle = query.toLowerCase().trim(); + if (!needle) return scoreBrowseDepth(normalized); + const directScore = scorePathForNeedle(normalized, needle); + if (directScore >= 0) return directScore; + + if (!allowComposerPrefixFallback) return -1; + + // 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. This is kept + // behind an explicit composer-only mode so generic quick-open searches keep + // their whole-query semantics. + const isRootLevelPath = !normalized.includes("/") && !normalized.includes("\\"); + 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 = isRootLevelPath + ? (normalized.startsWith(prefix) ? 600 : -1) + : 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); + } + return best; } async function cooperativeYield(): Promise { @@ -222,7 +249,8 @@ export function createFileSearchIndexService() { } }; - const quickOpenCacheKey = (query: string, limit: number): string => `${query.toLowerCase().trim()}\0${limit}`; + const quickOpenCacheKey = (query: string, limit: number, allowComposerPrefixFallback: boolean): string => + `${query.toLowerCase().trim()}\0${limit}\0${allowComposerPrefixFallback ? "composer" : "generic"}`; const rememberQuickOpenCache = (index: WorkspaceIndex, cacheKey: string, items: FilesQuickOpenItem[]): void => { if (index.quickOpenCache.has(cacheKey)) { @@ -334,6 +362,7 @@ export function createFileSearchIndexService() { query: string; limit: number; includeIgnored: boolean; + allowComposerPrefixFallback?: boolean; shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise; primeIgnoreCache?: (relPaths: string[], includeIgnored: boolean) => Promise; }): Promise { @@ -343,13 +372,14 @@ export function createFileSearchIndexService() { primeIgnoreCache: args.primeIgnoreCache }); - const cacheKey = quickOpenCacheKey(args.query, args.limit); + const allowComposerPrefixFallback = Boolean(args.allowComposerPrefixFallback); + const cacheKey = quickOpenCacheKey(args.query, args.limit, allowComposerPrefixFallback); const cached = index.quickOpenCache.get(cacheKey); if (cached) return cloneQuickOpenItems(cached); const scored: FilesQuickOpenItem[] = []; for (const entry of index.files.values()) { - const score = scorePath(entry.lowerPath, args.query); + const score = scorePath(entry.lowerPath, args.query, allowComposerPrefixFallback); if (score < 0) continue; scored.push({ path: entry.path, score }); } diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index 18c655830..1936d8f92 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -539,6 +539,106 @@ 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, + allowComposerPrefixFallback: true, + }); + + expect(quickOpen.map((item) => item.path)).toContain("src/my folder"); + } finally { + removeTestTree(rootPath); + } + }); + + it("matches a root-level spaced extensionless file before trailing prose", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-root-spaced-prose-")); + const { execSync } = await import("node:child_process"); + execSync("git init", { cwd: rootPath, stdio: "ignore" }); + const laneService = createLaneServiceStub(rootPath); + const service = createFileService({ laneService }); + + try { + fs.writeFileSync(path.join(rootPath, "my file"), "extensionless root file\n", "utf8"); + + const quickOpen = await service.quickOpen({ + workspaceId: "workspace-1", + query: "my file review this", + includeIgnored: true, + allowComposerPrefixFallback: true, + }); + + expect(quickOpen.map((item) => item.path)).toContain("my file"); + } finally { + removeTestTree(rootPath); + } + }); + + it("matches a nested extensionless basename before trailing prose", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-nested-basename-prose-")); + 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, "docs"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "docs", "README"), "nested extensionless file\n", "utf8"); + + const quickOpen = await service.quickOpen({ + workspaceId: "workspace-1", + query: "README review this", + includeIgnored: true, + allowComposerPrefixFallback: true, + }); + + expect(quickOpen.map((item) => item.path)).toContain("docs/README"); + } finally { + removeTestTree(rootPath); + } + }); + + it("keeps composer prefix fallback out of generic quickOpen", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-generic-prefix-")); + const { execSync } = await import("node:child_process"); + execSync("git init", { cwd: rootPath, stdio: "ignore" }); + const laneService = createLaneServiceStub(rootPath); + const service = createFileService({ laneService }); + + try { + fs.writeFileSync(path.join(rootPath, "package.json"), "{}\n", "utf8"); + + const generic = await service.quickOpen({ + workspaceId: "workspace-1", + query: "package manager", + includeIgnored: true, + }); + const composer = await service.quickOpen({ + workspaceId: "workspace-1", + query: "package manager", + includeIgnored: true, + allowComposerPrefixFallback: true, + }); + + expect(generic.map((item) => item.path)).not.toContain("package.json"); + expect(composer.map((item) => item.path)).toContain("package.json"); + } 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"); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index c3aa954bf..0e9511c17 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -1461,6 +1461,7 @@ export function createFileService({ query, limit, includeIgnored: Boolean(args.includeIgnored), + allowComposerPrefixFallback: Boolean(args.allowComposerPrefixFallback), shouldIgnore: shouldIgnoreForRoot(workspace.rootPath), primeIgnoreCache: primeIgnoreCacheForRoot(workspace.rootPath) }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 01652259b..1e6dda513 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -682,6 +682,222 @@ 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("does not reopen the file menu after a confirmed token and trailing prose", () => { + const onSearchAttachments = vi.fn().mockResolvedValue([{ path: "src/foo.ts", type: "file" }]); + + renderComposer({ + turnActive: false, + draft: "", + sessionId: "session-1", + attachments: [{ path: "src/foo.ts", type: "file" }], + onSearchAttachments, + }); + + const draft = "ask @src/foo.ts about this"; + fireEvent.change(screen.getByRole("textbox"), { + target: { value: draft, selectionStart: draft.length }, + }); + + expect(document.body.querySelector(".ade-chat-drawer-glass")).toBeNull(); + expect(onSearchAttachments).not.toHaveBeenCalled(); + }); + + it("keeps trailing prose when selecting a shorthand file match", async () => { + const onSearchAttachments = vi.fn().mockResolvedValue([{ path: "src/foo.ts", type: "file" }]); + const props = buildComposerProps({ + turnActive: false, + draft: "", + sessionId: "session-1", + onSearchAttachments, + }); + const view = render(); + const draft = "ask @foo.ts about this"; + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: draft, selectionStart: draft.length }, + }); + view.rerender(); + + await waitFor(() => expect(onSearchAttachments).toHaveBeenCalledWith("foo.ts")); + fireEvent.click(await screen.findByText("foo.ts")); + + expect(props.onDraftChange).toHaveBeenLastCalledWith("ask @src/foo.ts about this"); + }); + + 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("preserves trailing prose when selecting an extensionless path prefix", async () => { + const onSearchAttachments = vi.fn().mockResolvedValue([{ path: "src/my folder", type: "file" }]); + const props = buildComposerProps({ + turnActive: false, + draft: "", + sessionId: "session-1", + onSearchAttachments, + }); + const view = render(); + const draft = "ask @src/my review this"; + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: draft, selectionStart: draft.length }, + }); + view.rerender(); + + await waitFor(() => expect(onSearchAttachments).toHaveBeenCalledWith("src/my review this")); + fireEvent.click(await screen.findByText("my folder")); + + expect(props.onDraftChange).toHaveBeenLastCalledWith("ask @src/my folder review this"); + }); + + 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(); + const textbox = screen.getByRole("textbox"); + + fireEvent.change(textbox, { + target: { value: "@a b c", selectionStart: 6 }, + }); + view.rerender(); + + 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(); + + 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(); + expect(first.container.querySelector("[data-composer-mention-display]")?.textContent).toBe("a b c"); + + first.unmount(); + const second = render(); + 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(); + expect(first.container.querySelector("[data-composer-chip='mention']")?.textContent).toBe("a b c"); + + first.unmount(); + const second = render(); + expect(second.container.querySelector("[data-composer-chip='mention']")?.textContent).toBe("a b c"); + }); + + it("falls back to the canonical mention token when a persisted rich mention label is cleared", () => { + const props = buildComposerProps({ + turnActive: false, + draft: "@chat:chat-1 ", + mentionLabels: { "@chat:chat-1": "a b c" }, + iosElementContextItems: [makeIosContextItem("ios-1")], + }); + const view = render(); + const chip = () => view.container.querySelector("[data-composer-chip='mention']"); + + expect(chip()?.textContent).toBe("a b c"); + view.rerender(); + + expect(chip()?.textContent).toBe("@chat:chat-1"); + expect(chip()?.title).toBe("@chat:chat-1"); + }); + + 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(); + const textbox = screen.getByRole("textbox"); + const draft = "ask @a b c about this"; + + fireEvent.change(textbox, { + target: { value: draft, selectionStart: draft.length }, + }); + view.rerender(); + + 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" }]); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ac1aecf4b..bb49e689e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -41,6 +41,8 @@ import type { } from "../../../shared/types/orchestration"; import { getModelById, modelSupportsFastMode, type ProviderFamily } from "../../../shared/modelRegistry"; import { + composerTriggerForSelection, + composerTriggerHasConfirmedPrefix, composerTriggerSpansWholeDraft, detectComposerTrigger, findConfirmedComposerTokens, @@ -50,6 +52,7 @@ import { import { formatChatMentionToken, isChatMentionTokenBody, + parseChatMentions, } from "../../../shared/chatMentions"; import type { ChatMentionSuggestion } from "../../../shared/types/chatMentions"; import { cn } from "../ui/cn"; @@ -1508,6 +1511,8 @@ export function AgentChatComposer({ onReasoningEffortChange, onFastModeChange, onDraftChange, + mentionLabels, + onMentionLabelChange, onClearDraft, onSubmit, onSubmitBlocked, @@ -1662,6 +1667,9 @@ export function AgentChatComposer({ onReasoningEffortChange: (reasoningEffort: string | null) => void; onFastModeChange?: (enabled: boolean) => void; onDraftChange: (value: string) => void; + /** Persisted display labels keyed by their canonical mention token. */ + mentionLabels?: Record; + onMentionLabelChange?: (token: string, title: string) => void; onClearDraft?: () => void; onSubmit: () => void; onSubmitBlocked?: (message: string) => void; @@ -1853,6 +1861,15 @@ export function AgentChatComposer({ const richEditorRef = useRef(null); const richSelectionRef = useRef(null); const richInitializedRef = useRef(false); + // Plain textarea chips are painted by an overlay, while the serialized + // draft intentionally stores only the opaque mention pointer. Keep the + // selected row's title separately so the visible chip stays user-facing. + // This is a presentation cache only; send-time parsing still uses the + // canonical @chat: token in `draft`. + const mentionLabelsRef = useRef>(new Map(Object.entries(mentionLabels ?? {}))); + useLayoutEffect(() => { + mentionLabelsRef.current = new Map(Object.entries(mentionLabels ?? {})); + }, [mentionLabels]); const lastSerializedDraftRef = useRef(""); const lastPlainSelectionRef = useRef(null); const fileAddInProgressRef = useRef(false); @@ -2004,12 +2021,30 @@ export function AgentChatComposer({ let pos = 0; plainComposerTokens.forEach((token, index) => { if (token.start > pos) segments.push(draft.slice(pos, token.start)); + const tokenText = draft.slice(token.start, token.end); + const displayText = token.kind === "mention" + ? mentionLabels?.[tokenText]?.trim() || mentionLabelsRef.current.get(tokenText)?.trim() || tokenText + : tokenText; + const isLabeledMention = token.kind === "mention" && displayText !== tokenText; segments.push( - {draft.slice(token.start, token.end)} + {isLabeledMention ? ( + // Keep the textarea's canonical token as an invisible layout slot. + // The visible title is positioned inside that slot so a longer or + // shorter label cannot move the caret or following prose out of + // alignment with the real textarea value. + + + {tokenText} + + + {displayText} + + + ) : displayText} , ); pos = token.end; @@ -2019,7 +2054,7 @@ export function AgentChatComposer({ // measurable so the overlay height matches the textarea's scrollHeight. segments.push("​"); return segments; - }, [draft, plainComposerTokens]); + }, [draft, mentionLabels, plainComposerTokens]); // Pre-warm the lane's quick-open file index as soon as the composer is // bound to a session so the first "@" query is served from a warm index. @@ -2655,6 +2690,13 @@ export function AgentChatComposer({ setCommandMenuTrigger(null); return; } + if (composerTriggerHasConfirmedPrefix(node.value, trigger, { + isFile: (body) => attachedPaths.has(body), + isMention: isChatMentionTokenBody, + })) { + setCommandMenuTrigger(null); + return; + } if (!openIfNew) { setCommandMenuTrigger((current) => { if (!current) return current; @@ -2667,7 +2709,7 @@ export function AgentChatComposer({ setCommandMenuTrigger(trigger); const anchor = getCommandMenuAnchor(node); if (anchor) setCommandMenuAnchor(anchor); - }, []); + }, [attachedPaths]); const restoreTextareaCaret = useCallback((caret: number) => { lastPlainSelectionRef.current = caret; @@ -2707,13 +2749,68 @@ export function AgentChatComposer({ return chip; }, []); + const hydrateMentionChipsInEditor = useCallback((): boolean => { + const editor = richEditorRef.current; + const labels = mentionLabelsRef.current; + if (!editor) return false; + + let changed = false; + editor.querySelectorAll("[data-composer-chip='mention']").forEach((chip) => { + const token = chip.dataset.composerChipText; + if (!token) return; + const label = labels.get(token)?.trim() || token; + const labelNode = chip.firstElementChild; + if (labelNode && labelNode.textContent !== label) { + labelNode.textContent = label; + changed = true; + } + const title = label === token ? token : `${label} — ${token}`; + if (chip.title !== title) { + chip.title = title; + changed = true; + } + }); + + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent || parent.closest("[data-composer-chip], [data-ios-context-id], [data-app-control-context-id], [data-built-in-browser-context-id]")) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + const nodes: Text[] = []; + let current = walker.nextNode(); + while (current) { + nodes.push(current as Text); + current = walker.nextNode(); + } + for (const node of nodes) { + const text = node.textContent ?? ""; + const mentions = parseChatMentions(text).filter((mention) => labels.has(mention.token)); + if (!mentions.length) continue; + const fragment = document.createDocumentFragment(); + let offset = 0; + for (const mention of mentions) { + if (mention.start > offset) fragment.append(document.createTextNode(text.slice(offset, mention.start))); + fragment.append(createComposerTokenChipNode("mention", mention.token, labels.get(mention.token))); + offset = mention.end; + } + if (offset < text.length) fragment.append(document.createTextNode(text.slice(offset))); + node.replaceWith(fragment); + changed = true; + } + return changed; + }, [createComposerTokenChipNode]); + // Finds the in-progress /command or @file token that ends at the caret in // the rich contenteditable. Works on the DOM text run around the caret // instead of serialized-draft offsets: serialization collapses whitespace // and flattens chips, so serialized indices cannot be mapped back onto DOM // positions. Chips,
, and block edges terminate the run and act as // word boundaries. - const getRichTriggerContext = useCallback((): { trigger: ComposerTrigger; range: Range } | null => { + const getRichTriggerContext = useCallback((queryOverride?: string): { trigger: ComposerTrigger; range: Range } | null => { const editor = richEditorRef.current; if (!editor) return null; const selection = window.getSelection(); @@ -2741,8 +2838,11 @@ export function AgentChatComposer({ walker = walker.previousSibling; } - const trigger = detectComposerTrigger(runText, runText.length); - if (!trigger) return null; + const detectedTrigger = detectComposerTrigger(runText, runText.length); + if (!detectedTrigger) return null; + const trigger = queryOverride == null + ? detectedTrigger + : { ...detectedTrigger, query: queryOverride }; let remaining = trigger.start; let startNode: Text = caretNode; @@ -2757,9 +2857,22 @@ export function AgentChatComposer({ remaining -= length; } + let endRemaining = trigger.start + 1 + trigger.query.length; + let endNode: Text = caretNode; + let endOffset = caretOffset; + for (const node of runNodes) { + const length = node === caretNode ? caretOffset : (node.textContent ?? "").length; + if (endRemaining <= length) { + endNode = node; + endOffset = endRemaining; + break; + } + endRemaining -= length; + } + const range = document.createRange(); range.setStart(startNode, startOffset); - range.setEnd(caretNode, caretOffset); + range.setEnd(endNode, endOffset); return { trigger, range }; }, []); @@ -2768,7 +2881,7 @@ export function AgentChatComposer({ // no trigger span can be located (caller falls back to caret insertion). const replaceRichTriggerWith = useCallback((insertion: | { text: string } - | { chipKind: "file" | "command" | "mention"; chipText: string; chipLabel?: string } + | { chipKind: "file" | "command" | "mention"; chipText: string; chipLabel?: string; triggerLabel?: string } ): boolean => { const editor = richEditorRef.current; if (!editor) return false; @@ -2779,7 +2892,18 @@ export function AgentChatComposer({ selection?.removeAllRanges(); selection?.addRange(saved); } - const context = getRichTriggerContext(); + const detectedContext = getRichTriggerContext(); + if (!detectedContext) return false; + const trigger = "triggerLabel" in insertion + ? composerTriggerForSelection( + detectedContext.trigger, + insertion.triggerLabel ?? "", + insertion.chipKind === "file" ? "file" : "mention", + ) + : detectedContext.trigger; + const context = trigger.query === detectedContext.trigger.query + ? detectedContext + : getRichTriggerContext(trigger.query); if (!context) return false; selection?.removeAllRanges(); selection?.addRange(context.range); @@ -3011,6 +3135,8 @@ export function AgentChatComposer({ } } + hydrateMentionChipsInEditor(); + const isFocusedInsideEditor = document.activeElement === editor; const insertChipFragment = (chip: HTMLElement) => { const before = document.createTextNode(" "); @@ -3088,7 +3214,7 @@ export function AgentChatComposer({ if (next === lastSerializedDraftRef.current) return; lastSerializedDraftRef.current = next; onDraftChange(next); - }, [appControlContextItems, builtInBrowserContextItems, createAppControlContextChipNode, createBuiltInBrowserContextChipNode, createIosContextChipNode, draft, insertNodeAtTextOffset, iosElementContextItems, onDraftChange, serializeRichEditor, tokenizeSmartLinksInEditor, useRichComposer]); + }, [appControlContextItems, builtInBrowserContextItems, createAppControlContextChipNode, createBuiltInBrowserContextChipNode, createIosContextChipNode, draft, hydrateMentionChipsInEditor, insertNodeAtTextOffset, iosElementContextItems, mentionLabels, onDraftChange, serializeRichEditor, tokenizeSmartLinksInEditor, useRichComposer]); // ── Chip selection highlight ───────────────────────────────────────────── // The native selection is not painted over contentEditable="false" chips, so @@ -3873,11 +3999,16 @@ export function AgentChatComposer({ } // Replace exactly the @query trigger span with the confirmed token. if (useRichComposer) { - if (!replaceRichTriggerWith({ chipKind: "file", chipText: `@${item.path}` })) { + if (!replaceRichTriggerWith({ + chipKind: "file", + chipText: `@${item.path}`, + triggerLabel: item.path, + })) { insertTextIntoRichEditor(`@${item.path} `); } } else { - const next = replaceComposerTriggerSpan(draft, commandMenuTrigger, `@${item.path} `); + const trigger = composerTriggerForSelection(commandMenuTrigger, item.path, "file"); + const next = replaceComposerTriggerSpan(draft, trigger, `@${item.path} `); onDraftChange(next.text); restoreTextareaCaret(next.caret); } @@ -3886,16 +4017,20 @@ export function AgentChatComposer({ // A mention is a pointer, not an attachment: nothing is resolved or read // now. The token is expanded into an block at send time. const token = formatChatMentionToken(item.mention.kind, item.mention.id); + mentionLabelsRef.current.set(token, item.mention.title); + onMentionLabelChange?.(token, item.mention.title); if (useRichComposer) { if (!replaceRichTriggerWith({ chipKind: "mention", chipText: token, chipLabel: item.mention.title, + triggerLabel: item.mention.title, })) { insertTextIntoRichEditor(`${token} `); } } else { - const next = replaceComposerTriggerSpan(draft, commandMenuTrigger, `${token} `); + const trigger = composerTriggerForSelection(commandMenuTrigger, item.mention.title, "mention"); + const next = replaceComposerTriggerSpan(draft, trigger, `${token} `); onDraftChange(next.text); restoreTextareaCaret(next.caret); } @@ -3921,7 +4056,7 @@ export function AgentChatComposer({ } } setCommandMenuTrigger(null); - }, [attachBlockedReason, canAttach, commandMenuTrigger, composerInputLocked, draft, effectiveSlashCommands, handleSlashSelect, insertTextIntoRichEditor, onDraftChange, onAddAttachment, replaceRichTriggerWith, restoreTextareaCaret, useRichComposer]); + }, [attachBlockedReason, canAttach, commandMenuTrigger, composerInputLocked, draft, effectiveSlashCommands, handleSlashSelect, insertTextIntoRichEditor, onAddAttachment, onDraftChange, onMentionLabelChange, replaceRichTriggerWith, restoreTextareaCaret, useRichComposer]); const handleRichEditorInput = useCallback((event?: React.FormEvent) => { const editor = richEditorRef.current; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index b01c1bf01..185fd2363 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -68,6 +68,7 @@ import { normalizeChatContextAttachments, removeChatContextAttachment, } from "../../../shared/chatContextAttachments"; +import { isChatMentionTokenBody } from "../../../shared/chatMentions"; import type { OrchestrationAnnotationEventDetail, OrchestrationContextItem, @@ -1447,6 +1448,7 @@ type LastLaunchConfig = { type ComposerDraftStorageSnapshot = { version: 1; text: string; + mentionLabels: Record; modelId: string; reasoningEffort: string | null; fastMode: boolean; @@ -2707,6 +2709,20 @@ function mergeComposerItemsById(current: T[], incoming return [...merged.values()]; } +function normalizeComposerMentionLabels(value: unknown): Record { + if (!isRecord(value)) return {}; + const labels: Record = {}; + for (const [token, rawLabel] of Object.entries(value)) { + if (!token.startsWith("@") || !isChatMentionTokenBody(token.slice(1))) continue; + if (typeof rawLabel !== "string") continue; + const label = rawLabel.trim(); + if (!label || token.length > 300 || label.length > 500) continue; + labels[token] = label; + if (Object.keys(labels).length >= 64) break; + } + return labels; +} + function normalizeStoredComposerDraft( value: unknown, defaults: NativeControlState, @@ -2717,6 +2733,7 @@ function normalizeStoredComposerDraft( return { version: 1, text: typeof value.text === "string" ? value.text : "", + mentionLabels: normalizeComposerMentionLabels(value.mentionLabels), modelId, reasoningEffort: nonEmptyString(value.reasoningEffort), fastMode: modelSupportsFastMode(desc) && readStoredFastMode(value), @@ -3592,6 +3609,7 @@ export function AgentChatPane({ const [sdkSlashCommands, setSdkSlashCommands] = useState([]); const [sendOnEnter, setSendOnEnter] = useState(true); const [draft, setDraft] = useState(""); + const [mentionLabels, setMentionLabels] = useState>({}); const draftsPerSessionRef = useRef>(new Map()); const composerDraftWriteTimerRef = useRef(null); const pendingComposerDraftWriteRef = useRef<{ @@ -4121,6 +4139,13 @@ export function AgentChatPane({ draftsPerSessionRef.current.set(companionStateKey, value); if (value.length > 0) clearPromptSuggestionForSession(selectedSessionId); }, [clearPromptSuggestionForSession, companionStateKey, selectedSessionId]); + const updateComposerMentionLabel = useCallback((token: string, title: string) => { + const label = title.trim(); + if (!label) return; + setMentionLabels((current) => current[token] === label + ? current + : { ...current, [token]: label }); + }, []); const insertComposerDraft = useCallback((value: string) => { setDraft((current) => { const next = current.trim().length ? `${current.trimEnd()}\n\n${value}` : value; @@ -7632,7 +7657,8 @@ export function AgentChatPane({ const hits = await window.ade.files.quickOpen({ workspaceId: laneId, query: trimmed, - limit: 60 + limit: 60, + allowComposerPrefixFallback: true, }, pin); return hits.map((hit) => ({ path: hit.path, @@ -8180,6 +8206,7 @@ export function AgentChatPane({ writeComposerDraftSnapshot(storageKey, saved); } draftsPerSessionRef.current.set(companionStateKey, saved.text); + setMentionLabels(saved.mentionLabels); setDraft(saved.text); draftAttachmentOwnerBindingRef.current = saved.attachmentOwnerBinding; setAttachments(saved.attachments); @@ -8204,6 +8231,7 @@ export function AgentChatPane({ return; } const savedText = draftsPerSessionRef.current.get(companionStateKey) ?? ""; + setMentionLabels({}); setDraft(savedText); draftAttachmentOwnerBindingRef.current = null; setAttachments([]); @@ -8232,6 +8260,7 @@ export function AgentChatPane({ const snapshot: ComposerDraftStorageSnapshot = { version: 1, text: draft, + mentionLabels, modelId, reasoningEffort, fastMode, @@ -8278,6 +8307,7 @@ export function AgentChatPane({ executionMode, iosElementContextItems, modelId, + mentionLabels, reasoningEffort, ]); @@ -12198,6 +12228,8 @@ export function AgentChatPane({ onReasoningEffortChange={handleReasoningEffortChange} onFastModeChange={handleFastModeChange} onDraftChange={updateComposerDraft} + mentionLabels={mentionLabels} + onMentionLabelChange={updateComposerMentionLabel} onClearDraft={() => updateComposerDraft("")} onSubmit={() => { void submit(); @@ -12245,6 +12277,7 @@ export function AgentChatPane({ const draftSnapshot: ComposerDraftStorageSnapshot = { version: 1, text: draft, + mentionLabels, modelId, reasoningEffort, fastMode, diff --git a/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx b/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx index 15948d986..1fc17923a 100644 --- a/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatCommandMenu.tsx @@ -20,7 +20,7 @@ import { Terminal as TerminalIcon, type Icon as PhosphorIcon, } from "@phosphor-icons/react"; -import type { ComposerTrigger } from "../../../shared/composerTriggers"; +import { composerFileSearchQuery, type ComposerTrigger } from "../../../shared/composerTriggers"; import { CHAT_MENTION_KINDS, CHAT_MENTION_MAX_PER_KIND } from "../../../shared/chatMentions"; import type { ChatMentionKind, ChatMentionSuggestion } from "../../../shared/types/chatMentions"; import { cn } from "../ui/cn"; @@ -294,12 +294,13 @@ export const ChatCommandMenu = forwardRef( atActive, - atQuery, + fileQuery, onFileSearch, MAX_FILE_RESULTS, triggerType, diff --git a/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx b/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx index fd6095d73..552a4eef8 100644 --- a/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx @@ -15,7 +15,8 @@ import { import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; import { NO_CI_REASON } from "../../../shared/prChecksRollup"; -import { lanePrAttention, lanePrAttentionColor, lanePrAttentionRank } from "../../lib/lanePrBadge"; +import { lanePrAggregateAttention, lanePrAttention, lanePrAttentionColor, pickPrimaryPr } from "../../lib/lanePrBadge"; +import { LanePrHoverCard } from "./LanePrHoverCard"; /** Caption beneath the state badge: "PR opened / merged / draft / closed". */ function prStateCaption(state: LaneTabPrTag["state"]): string { @@ -92,24 +93,57 @@ export function LanePrBadgePopover({ onOpenList?: () => void; }) { const allPrs = prs?.length ? prs : legacyPr ? [legacyPr] : []; - const primaryPr = allPrs.length > 1 - ? allPrs.reduce((best, candidate) => ( - lanePrAttentionRank(candidate) > lanePrAttentionRank(best) ? candidate : best - ), allPrs[0]!) - : allPrs[0] ?? null; + const primaryPr = pickPrimaryPr(allPrs) ?? allPrs[0] ?? null; if (!primaryPr) return null; if (allPrs.length > 1) { - const aggregateColor = lanePrAttentionColor(lanePrAttention(primaryPr)); + const aggregateColor = lanePrAttentionColor(lanePrAggregateAttention(allPrs)); const countClass = "rounded-full border border-white/[0.08] bg-white/[0.03] px-1.5 py-px font-mono text-[9px] font-semibold text-muted-fg/65"; const activate = (event: React.SyntheticEvent, candidate = primaryPr) => { event.stopPropagation(); onActivate(event, candidate); }; return ( - event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} + +
+ Pull requests ({allPrs.length}) +
+ {allPrs.map((candidate) => ( +
activate(event, candidate)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(event, candidate); + } + }} + title={candidate.title} + > + + + + #{candidate.githubPrNumber} + {candidate.state} + {candidate.laneRole === "previous" ? previous : null} + + {candidate.title || "Untitled pull request"} + + + + + +
+ ))} + + )} >