From e6c972eabd3c9ebb28e9f9547d3931a34a9d6cf2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:48:37 -0400 Subject: [PATCH] fix(desktop): let Cursor chats Read attached images Cursor copies inlined screenshots to ~/.cursor/projects//assets and oversized attaches live at /.ade/attachments, both outside a worktree lane. The host hook denied those Reads, so the model claimed it could not open the image. Allow read-only of those two roots; keep writes, shell, other slugs, secrets, and redirected attachments denied. Co-authored-by: Cursor --- .../main/services/chat/agentChatService.ts | 1 + .../services/chat/cursorSdkPolicy.test.ts | 168 ++++++++++++++++++ .../src/main/services/chat/cursorSdkPolicy.ts | 46 +++++ .../main/services/chat/cursorSdkPool.test.ts | 1 + .../src/main/services/chat/cursorSdkPool.ts | 1 + .../main/services/chat/cursorSdkProtocol.ts | 2 + .../src/main/services/chat/cursorSdkWorker.ts | 1 + docs/features/chat/README.md | 16 +- docs/features/chat/agent-routing.md | 10 ++ docs/features/chat/composer-and-ui.md | 2 +- 10 files changed, 246 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 93ec5b1b3..d15031661 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -39370,6 +39370,7 @@ export function createAgentChatService(args: { request: req, policy, laneRoot: managed.laneWorktreePath, + projectRoot, sessionAllowedTools: runtime.sdkApprovedTools, userHomeDir: resolveCursorSdkUserHome(), }); diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index cc184cf65..499e93a67 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -403,6 +403,174 @@ describe("Cursor SDK policy", () => { }, }, laneRoot); expect(evaluateCursorSdkHook({ request: writeTranscript, policy, laneRoot, userHomeDir })).toBe("deny"); + + const asset = summarizeCursorHook({ + toolName: "read", + toolInput: { + path: path.join(userHomeDir, ".cursor", "projects", slug, "assets", "shot.png"), + }, + }, laneRoot); + expect(evaluateCursorSdkHook({ request: asset, policy, laneRoot, userHomeDir })).toBe("allow"); + + const writeAsset = summarizeCursorHook({ + toolName: "write", + toolInput: { + path: path.join(userHomeDir, ".cursor", "projects", slug, "assets", "shot.png"), + contents: "x", + }, + }, laneRoot); + expect(evaluateCursorSdkHook({ request: writeAsset, policy, laneRoot, userHomeDir })).toBe("deny"); + + const otherSlug = `${slug}-other`; + const otherAsset = summarizeCursorHook({ + toolName: "read", + toolInput: { + path: path.join(userHomeDir, ".cursor", "projects", otherSlug, "assets", "shot.png"), + }, + }, laneRoot); + expect(evaluateCursorSdkHook({ request: otherAsset, policy, laneRoot, userHomeDir })).toBe("deny"); + }); + + it("allows read-only access to staged project attachments from a lane worktree", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-attach-")); + const projectRoot = path.join(root, "repo"); + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane"); + const attachmentsDir = path.join(projectRoot, ".ade", "attachments"); + const secretsDir = path.join(projectRoot, ".ade", "secrets"); + const imagePath = path.join(attachmentsDir, "00000000-0000-4000-8000-000000000001.png"); + const otherProject = path.join(root, "other-repo"); + const otherImage = path.join(otherProject, ".ade", "attachments", "shot.png"); + fs.mkdirSync(laneRoot, { recursive: true }); + fs.mkdirSync(attachmentsDir, { recursive: true }); + fs.mkdirSync(secretsDir, { recursive: true }); + fs.mkdirSync(path.dirname(otherImage), { recursive: true }); + fs.writeFileSync(imagePath, "png"); + fs.writeFileSync(path.join(secretsDir, "token"), "secret"); + fs.writeFileSync(otherImage, "png"); + + try { + const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); + const read = summarizeCursorHook({ + toolName: "read", + toolInput: { path: imagePath }, + }, laneRoot); + expect(evaluateCursorSdkHook({ + request: read, + policy, + laneRoot, + projectRoot, + })).toBe("allow"); + expect(evaluateCursorSdkHook({ + request: summarizeCursorHook({ + toolName: "read", + toolInput: { path: imagePath }, + }, laneRoot), + policy, + laneRoot, + })).toBe("deny"); + + const write = summarizeCursorHook({ + toolName: "write", + toolInput: { path: imagePath, contents: "x" }, + }, laneRoot); + expect(evaluateCursorSdkHook({ + request: write, + policy, + laneRoot, + projectRoot, + })).toBe("deny"); + + const secret = summarizeCursorHook({ + toolName: "read", + toolInput: { path: path.join(secretsDir, "token") }, + }, laneRoot); + expect(evaluateCursorSdkHook({ + request: secret, + policy, + laneRoot, + projectRoot, + })).toBe("deny"); + + const foreign = summarizeCursorHook({ + toolName: "read", + toolInput: { path: otherImage }, + }, laneRoot); + expect(evaluateCursorSdkHook({ + request: foreign, + policy, + laneRoot, + projectRoot, + })).toBe("deny"); + + const shell = summarizeCursorHook({ + toolName: "shell", + toolInput: { command: `cat ${imagePath}`, cwd: laneRoot }, + }, laneRoot); + expect(evaluateCursorSdkHook({ + request: shell, + policy, + laneRoot, + projectRoot, + })).toBe("deny"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("denies project attachment reads when attachments is symlinked onto secrets", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-attach-secrets-")); + const projectRoot = path.join(root, "repo"); + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane"); + const secretsDir = path.join(projectRoot, ".ade", "secrets"); + const attachmentsLink = path.join(projectRoot, ".ade", "attachments"); + fs.mkdirSync(laneRoot, { recursive: true }); + fs.mkdirSync(secretsDir, { recursive: true }); + fs.writeFileSync(path.join(secretsDir, "token"), "secret"); + fs.symlinkSync(secretsDir, attachmentsLink, "dir"); + + try { + const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); + const request = summarizeCursorHook({ + toolName: "read", + toolInput: { path: path.join(attachmentsLink, "token") }, + }, laneRoot); + expect(evaluateCursorSdkHook({ + request, + policy, + laneRoot, + projectRoot, + })).toBe("deny"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("denies project attachment reads when attachments is symlinked outside the project", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-attach-link-")); + const projectRoot = path.join(root, "repo"); + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane"); + const outside = path.join(root, "outside"); + const attachmentsLink = path.join(projectRoot, ".ade", "attachments"); + fs.mkdirSync(laneRoot, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, "shot.png"), "png"); + fs.symlinkSync(outside, attachmentsLink, "dir"); + + try { + const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); + const request = summarizeCursorHook({ + toolName: "read", + toolInput: { path: path.join(attachmentsLink, "shot.png") }, + }, laneRoot); + expect(evaluateCursorSdkHook({ + request, + policy, + laneRoot, + projectRoot, + })).toBe("deny"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); it.skipIf(process.platform === "win32")("denies Cursor support reads when the active project support root is symlinked outside Cursor projects", () => { diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts index a0b3e6d64..9f1d64dd3 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import path from "node:path"; import type { AgentChatSession } from "../../../shared/types"; +import { projectAttachmentsDir } from "../../../shared/chatAttachmentStagingFs"; import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; +import { pathComparisonKey } from "../shared/pathCompare"; import { isOrchestrationLeadSession, ORCHESTRATION_LEAD_ALLOWED_CURSOR_TOOL_RISKS, @@ -529,9 +531,12 @@ function cursorSupportReadRoots(laneRoot: string, userHomeDir?: string | null): const home = userHomeDir?.trim(); if (!home) return []; const projectRoot = path.join(home, ".cursor", "projects", cursorProjectSlugForPath(laneRoot)); + // `assets` is Cursor's own copy of inlined chat images for this workspace. + // The model is told to Read those files; they live outside the lane worktree. return [ path.join(projectRoot, "terminals"), path.join(projectRoot, "agent-transcripts"), + path.join(projectRoot, "assets"), ]; } @@ -559,12 +564,47 @@ function isAllowedCursorSupportRead(args: { return false; } +function isAllowedProjectAttachmentRead(args: { + candidatePath: string; + projectRoot?: string | null; + risk: CursorSdkHookRequest["risk"]; +}): boolean { + if (args.risk !== "read") return false; + // Worker init always passes the ADE project root. Fail closed rather than + // guessing from the lane path — a worktree chat must not inherit the + // project's attachments grant from layout inference. + const explicit = args.projectRoot?.trim(); + if (!explicit) return false; + const resolvedProject = path.resolve(explicit); + const projectReal = realPathWithNearestExistingAncestor(resolvedProject); + const adeReal = realPathWithNearestExistingAncestor(path.join(resolvedProject, ".ade")); + const attachmentsReal = realPathWithNearestExistingAncestor( + projectAttachmentsDir(resolvedProject), + ); + const secretsReal = realPathWithNearestExistingAncestor( + path.join(resolvedProject, ".ade", "secrets"), + ); + // Same shape as the Cursor projects symlink guard, plus a basename check + // after realpath so a junction/symlink from `attachments` onto `.ade` or + // `.ade/secrets` cannot inherit this grant. + if (!isWithinPath(projectReal, adeReal) || !isWithinPath(adeReal, attachmentsReal)) { + return false; + } + if (pathComparisonKey(path.basename(attachmentsReal)) !== pathComparisonKey("attachments")) { + return false; + } + const candidateReal = realPathWithNearestExistingAncestor(args.candidatePath); + if (isWithinPath(secretsReal, candidateReal)) return false; + return isWithinPath(attachmentsReal, candidateReal); +} + function pathGuardReason(args: { laneRoot: string; cwd: string; value: unknown; risk: CursorSdkHookRequest["risk"]; userHomeDir?: string | null; + projectRoot?: string | null; }): string | null { const laneRoot = path.resolve(args.laneRoot); const laneRootReal = realPathWithNearestExistingAncestor(laneRoot); @@ -601,6 +641,10 @@ function pathGuardReason(args: { laneRoot, userHomeDir: args.userHomeDir, risk: args.risk, + }) || isAllowedProjectAttachmentRead({ + candidatePath: resolved, + projectRoot: args.projectRoot, + risk: args.risk, })) { continue; } @@ -624,6 +668,7 @@ export function evaluateCursorSdkHook(args: { request: CursorSdkHookRequest; policy: CursorSdkPermissionPolicy; laneRoot: string; + projectRoot?: string | null; sessionAllowedTools?: Set; userHomeDir?: string | null; }): "allow" | "deny" | "ask" { @@ -634,6 +679,7 @@ export function evaluateCursorSdkHook(args: { value: args.request.toolInput ?? args.request.raw, risk: args.request.risk, userHomeDir: args.userHomeDir, + projectRoot: args.projectRoot, }) : null; if (guardReason) { diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts index 7bb3ef8c3..774e28bf3 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts @@ -1103,6 +1103,7 @@ describe("Cursor SDK pool paths", () => { modelSdkId: "grok-4.6", apiKey: "cursor-test-key", sessionId: "oneshot:session_title", + projectRoot: path.join(os.tmpdir(), "ade-project"), laneRoot: workspacePath, // Fixed, both of them: the warm worker is shared across features and // keeps the policy and the name it was created with. diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts index 84843dded..05faea612 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts @@ -908,6 +908,7 @@ async function createCursorSdkConnection(args: Parameters/` and for `/.ade/attachments` when worker init supplies that project root (a missing root fails closed and is not inferred from the lane; after realpath the directory basename must be `attachments`; writes, shell, other slugs, `.ade/secrets`, and symlink-out-of-tree remain denied). | | `apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.ts` | Builds the system prompt the Cursor worker injects (lane context, ADE CLI guidance, persona overlays). | | `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. SDK `task` messages remain parent-run activity summaries; typed `Task` tool calls/results produce subagent start/result events keyed by tool call id, including the returned child agent id when available. Cursor MCP calls retain provider/tool identity in `event.mcp`; generated-image tools become compact image-generation rows. On a terminal `ERROR` status it reads the worker-injected `adeErrorCode` / `adeErrorDetail`, emits stable user-facing headlines for rate-limit and transport failures (a transport failure reads **Cursor's connection dropped mid-run.** rather than leaking `NGHTTP2_INTERNAL_ERROR` or `[internal] write ECANCELED` into the transcript), preserves exact Cursor request ids/details in `detail`, and sets `errorInfo.category` to `rate_limit`, `network`, `busy`, or `auth` when classification is known. Whenever the friendly headline replaces the raw code, that code is kept as the first `detail` line so the underlying failure is still recoverable from the transcript. | | `apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts` | Probes the live `@cursor/sdk` and `cursor-agent` CLI model lists, merges their descriptors, and records `cursorAvailability` so chat sessions see SDK-capable models while Work CLI launches can include CLI-only models. Both JSON and text probes preserve aliases, descriptions, `parameters[]`, and `variants[]`; `*-fast` CLI rows are folded into their base model as `aliases` + `serviceTiers: ["fast"]` so the picker shows one model with a Fast toggle instead of duplicate "Fast" rows. Parameter and variant metadata is classified into `reasoningTiers` (`none`/`dynamic`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`/`thinking`) and `serviceTiers` (`fast`). `resolveCursorSdkModelSelectionParams` rebuilds the matching `CursorSdkModelParameterValue[]` so the SDK boot can target the right variant. The previous minimal `auto` / `composer-2` fallback list has been removed. **Cache resilience:** both the SDK and CLI caches are stale-while-revalidate — last-known-good rows are served well past the 120s freshness window (up to ~6h) and a background warm (at most one attempt per freshness window, so a broken CLI/SDK is not re-spawned on every passive read) refreshes them, so verified-provider models never blink out on passive status reads (`availableModelIds`, mobile, TUI). `markCursorModelCachesStale` ages the caches without dropping rows — generic readiness invalidation (forced status refresh, verifying any provider's key) calls it, while only a cursor key change does a full `clearCursorCliModelsCache`. Auth/SDK-resolution failures drop the SDK cache (a dead key/unusable module must not resurface phantom models); transient failures keep serving last-known-good. When the signed-in CLI reports "No models available" its cache is dropped and a provider runtime failure is surfaced (the stored login lost model access; re-auth via `cursor-agent logout`). | @@ -1819,6 +1819,20 @@ Provider connection management lives on the `ade.ai.*` surface (handled in `regi ## Fragile and tricky wiring +- **Cursor local chats Read images from two paths outside a lane worktree.** + ADE inlines attachment bytes into the Cursor SDK send, and Cursor then + copies them to `~/.cursor/projects//assets/` and tells the + model to Read that file. Oversized attachments that skip inlining are + named as `/.ade/attachments/.`, which is also + outside a worktree lane. The host hook allowlists those two roots for + **read** only (`cursorSdkPolicy.ts`). The attachments grant needs the + explicit ADE project root from worker init — a missing root fails + closed and does not walk up from the lane. After realpath, the + directory's basename must still be `attachments`, so a junction onto + `.ade` or `.ade/secrets` cannot inherit the grant. Writes, shell + `cat`, other projects' Cursor dirs, and `.ade/secrets` stay denied. + Do not copy attachments into the lane to work around this — that + dirties git and is the wrong fix. - **Caller MCP strict mode is Claude-only as a guarantee.** `strictMcpConfig: true` is a real isolation switch on the Claude Agent SDK (`opts.strictMcpConfig`). Every other provider is best-effort with a named diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 6d5f7e805..891f7eaae 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -727,6 +727,16 @@ surfaces. `resolveCursorSdkPolicy` (`services/chat/cursorSdkPolicy.ts`) turns the ADE permission mode into a `CursorSdkPermissionPolicy`: chat mode, approval policy, sandbox mode, hard guards, orchestration-lead flag, and a `fullAuto` marker. +Hard guards refuse paths outside the lane. Read-only exceptions are this +lane's Cursor `terminals`, `agent-transcripts`, and `assets` directories +under `~/.cursor/projects//`, plus the project's `.ade/attachments` +when worker init supplies that project root (Cursor inlines image bytes +then tells the model to Read the assets copy; oversized attachments are +also a path under `.ade/attachments`). The attachments grant fails closed +without that explicit root and does not infer it from the lane layout. +After realpath the directory basename must be `attachments`. Writes, +shell, other projects' Cursor dirs, `.ade/secrets`, and a junction onto +`.ade` or `.ade/secrets` stay denied. `buildCursorSdkLocalRunOptions` then reduces that policy to the SDK's local run options, where the sandbox is a three-state `CursorSdkSandboxDirective` (`enable` / `disable` / `inherit`) rather than a boolean — see diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 04e1f46b4..ad4028722 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -34,7 +34,7 @@ subagents, computer use). The pane derives all visible state from the | `ChatAttachmentTray.tsx`, `ChatAttachmentPreviewModal.tsx` | Inline file/image attachment tray, used both inside the composer and on sent user messages in the transcript. Image attachments render an inline thumbnail and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage`. Every other attachment renders as a chip: file-type icon from the Files tab's `getFileIcon`, middle-truncated filename (so the extension survives), human size when the caller knows it, and a remove ×. Chips are focusable — Delete/Backspace removes, Enter/Space opens, arrows move between attachments. Clicking any attachment opens `ChatAttachmentPreviewModal`, which renders the file with the **Files tab's own viewer platform**: `resolveViewerKind` picks the viewer and `ViewerHost` renders it, so PDFs, CSVs, media, office documents, markdown and code all preview read-only without forking a viewer. The modal is `React.lazy`-loaded because that platform pulls in Monaco and the document renderers, and the composer is on a hot render path. It locates the attachment with `resolveAttachmentWorkspaceTarget` (deepest containing Files workspace, Windows-separator aware) using the session's machine pin, so an attachment staged on a paired host is read from that host; an image outside every workspace falls back to the thumbnail bytes the chip already holds. Size is shown only for attachments this composer staged — a chip replayed from transcript history has only a path, and statting each one would be a round trip per chip. | | `chatAttachmentStaging.ts` | How ONE file is staged, given what the destination machine supports. `readAttachmentStagingMode(pin)` asks the chat's machine once per batch (`agentChat.getAttachmentStagingMode`) and never throws — a machine that cannot answer gets `CONSERVATIVE_ATTACHMENT_STAGING_MODE`, the base64 contract every host has supported since attachments existed. `planAttachmentStaging` then decides per file: the machine-level answer is necessary but not sufficient, because three things force the bytes leg even on a capable host — no source path (a clipboard paste, and every file in the hosted web client, where `webUtils` does not exist), a renderer-side conversion (HEIC is decoded to JPEG here, so what gets staged is a buffer this process produced), or the host saying `base64` outright. Whenever the bytes leg is taken the ceiling drops to `LEGACY_MAX_CHAT_ATTACHMENT_BYTES`, because that is what `chat.saveTempAttachment` actually enforces on the other end; returning the larger number would only move the rejection later. `stageAttachmentBytesFromFile` is that leg (chunked base64 encode, HEIC conversion, `saveTempAttachment`), and `AttachmentConversionError` marks the one failure the composer offers no retry for. | | `apps/desktop/src/shared/chatAttachmentLimits.ts` | The three ceilings and the two rejection messages, shared by the renderer, the desktop main process, and the CLI sync host. `MAX_CHAT_ATTACHMENT_BYTES` (50 MB) governs attachments that move as *files* — a local disk-to-disk copy or a streamed HTTP upload, where the bytes never sit in a JS string. `LEGACY_MAX_CHAT_ATTACHMENT_BYTES` (10 MB) governs attachments that move as base64 inside a command payload, which is buffered in memory on both ends and chunked into 720 KiB frames under a 25 MB payload cap over sync. `MAX_PROVIDER_INLINE_IMAGE_BYTES` (10 MB) is independent of both — see `attachmentInlineGuard.ts` in the chat [README](README.md#source-file-map). `formatAttachmentSize`, `legacyAttachmentCapMessage`, and `attachmentTooLargeMessage` render every ceiling from its constant, so raising one cannot leave a stale "10 MB" behind in a message. | -| `apps/desktop/src/shared/chatAttachmentStagingFs.ts` | Node-only disk rule for writing into `/.ade/attachments` — UUID basename, validated extension, containment re-check, stat-before-copy. Shared by desktop main, the ADE action registry, and the CLI sync host's upload route; full contract in the chat [README](README.md#source-file-map). | +| `apps/desktop/src/shared/chatAttachmentStagingFs.ts` | Node-only disk rule for writing into `/.ade/attachments` — UUID basename, validated extension, containment re-check, stat-before-copy. Shared by desktop main, the ADE action registry, and the CLI sync host's upload route; full contract in the chat [README](README.md#source-file-map). Cursor local chats may Read those staged files (and Cursor's own `~/.cursor/projects//assets` copies) through the SDK hook allowlist when worker init supplies the project root; writes stay denied. | | `attachmentViewerTarget.ts` | Locates a chat attachment inside a Files workspace so the Files viewers can open it. Attachments live at `/.ade/attachments/`, already inside the primary workspace, so this is a containment question rather than a new capability — and resolving it on the client keeps the whole lookup pin-aware, so an attachment staged on a paired host is read from that host instead of silently matching a same-named path here. Splits on both separators (a Windows attachment path resolves too), compares segment-for-segment through the shared `normalizePathForComparison` so `/a/ADE-backup` cannot match `/a/ADE`, and the longest matching root wins — an attachment inside a lane worktree resolves to the lane's workspace, not the project containing it. | | `ChatCommandMenu.tsx` | Popover for slash commands and the mixed `@` menu: files, chats, lanes, and terminals ranked together by match quality (not grouped or biased by kind), each row showing a kind icon. Consumes a `ComposerTrigger` from `shared/composerTriggers.ts` (so the menu opens for a mid-draft trigger, not just a leading one). Files and mentions are two independently debounced (40 ms) `useDebouncedSuggestions` sources sharing one hook; `rankComposerAtMenuItems` then scores file paths (basename as subtitle) against entity titles with the same exact/prefix/substring/subsequence tiers. Each keeps a per-menu-session query cache (`QUERY_CACHE_MAX = 40`) so cached queries render same-frame while a background revalidation still runs, and both caches clear when the menu closes or the provider identity changes. A bare `@` is a browse of recency-ranked entities (file search returns nothing until there is a query). Multi-word `@` queries stay active through spaces and use the same cached/debounced search path. Flat keyboard-nav indices are precomputed in the sections memo (no render-time counters); all three row types share the `MenuRow` chrome. Selecting a mention inserts an opaque `@chat:` / `@lane:` / `@term:` pointer while the composer displays a compact title chip with a kind icon (see `shared/chatMentions.ts`). An `onNoMatches?(trigger)` callback fires once when a **non-empty** `@` query settles with zero rows, so the owner can close the menu instead of leaving it parked over the draft while the user types the rest of a sentence; an empty query is a browse, not a search — it can legitimately show nothing now and match once the user types — so it never reports. `useDebouncedSuggestions` carries the `query` its results belong to alongside the provider identity, and results from a previous provider *or a previous query* are discarded **and count as still-loading**: state updates from this render's effects are not visible to consumers until the next render, so `loading` alone would read "settled" for one frame after every keystroke and fire a false no-match. | | `apps/desktop/src/shared/composerTriggers.ts` | Cursor-relative typed-trigger detection shared by the desktop chat composer (rich + textarea), the `WorkViewArea` continue composer, and the ade-code TUI (iOS mirrors the same regexes in Swift). `detectComposerTrigger(text, cursorPos)` finds an in-progress `/command` / `@` query ending at the cursor at any position; `@` queries may contain spaces for multi-word entity names but stop at a newline or another `@`; selecting a matching suggestion narrows the replacement span to its label so trailing prose is preserved; `replaceComposerTriggerSpan` splices exactly that span; `findConfirmedComposerTokens` locates confirmed chip tokens for overlay/prompt styling (`ComposerTokenKind` is `"file" | "command" | "mention"`; mention bodies are self-identifying via the `chat:`/`lane:`/`term:` prefix grammar, so callers pass a purely syntactic `isMention` predicate); `composerTriggerSpansWholeDraft` distinguishes a lone leading command from a mid-sentence one. It also owns popover dismissal: `ComposerTriggerDismissal` is `Pick` and `isComposerTriggerDismissed(trigger, dismissal)` reports whether a trigger is still covered by an earlier one. Suggestion search only *narrows* as the query grows — once nothing matched `@cursor`, `@cursor agent` cannot match either — so any extension of a dismissed query stays dismissed, while backspacing out of it, editing it into a different query, or typing a new `@` elsewhere all produce a genuinely new search and reopen. |