(null);
+ const { ref: panelRef, position } = useClampedFixedPosition(anchor, label);
+
+ const cancelClose = useCallback(() => {
+ if (closeTimerRef.current == null) return;
+ clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }, []);
+
+ const close = useCallback(() => {
+ cancelClose();
+ setAnchor(null);
+ }, [cancelClose]);
+
+ const scheduleClose = useCallback(() => {
+ const activeElement = typeof document !== "undefined" ? document.activeElement : null;
+ if (activeElement && (triggerRef.current?.contains(activeElement) || panelRef.current?.contains(activeElement))) {
+ return;
+ }
+ cancelClose();
+ closeTimerRef.current = setTimeout(() => {
+ closeTimerRef.current = null;
+ setAnchor(null);
+ }, CLOSE_DELAY_MS);
+ }, [cancelClose, panelRef]);
+
+ const open = useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+ cancelClose();
+ const rect = trigger.getBoundingClientRect();
+ setAnchor({ x: rect.left, y: rect.bottom + GAP });
+ }, [cancelClose]);
+
+ const isWithinCard = useCallback((target: EventTarget | null): boolean => {
+ return target instanceof Node && Boolean(
+ triggerRef.current?.contains(target) || panelRef.current?.contains(target),
+ );
+ }, [panelRef]);
+
+ useEffect(() => {
+ if (!anchor) return undefined;
+ const closeOnViewportChange = () => close();
+ window.addEventListener("scroll", closeOnViewportChange, true);
+ window.addEventListener("resize", closeOnViewportChange);
+ return () => {
+ window.removeEventListener("scroll", closeOnViewportChange, true);
+ window.removeEventListener("resize", closeOnViewportChange);
+ };
+ }, [anchor, close]);
+
+ useEffect(() => () => {
+ if (closeTimerRef.current != null) clearTimeout(closeTimerRef.current);
+ }, []);
+
+ const trigger = (
+ {
+ event.stopPropagation();
+ }}
+ onMouseDown={(event) => {
+ event.stopPropagation();
+ }}
+ onMouseEnter={open}
+ onMouseLeave={scheduleClose}
+ onFocus={open}
+ onBlur={(event) => {
+ if (!isWithinCard(event.relatedTarget)) scheduleClose();
+ }}
+ >
+ {children}
+
+ );
+
+ if (!anchor || typeof document === "undefined" || !document.body) return trigger;
+
+ const fallbackLeft = Math.min(
+ Math.max(VIEWPORT_PADDING, anchor.x),
+ Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
+ );
+ const panel = (
+ {
+ if (!isWithinCard(event.relatedTarget)) scheduleClose();
+ }}
+ style={{
+ position: "fixed",
+ zIndex: 9999,
+ left: position?.left ?? fallbackLeft,
+ top: position?.top ?? anchor.y,
+ width,
+ maxWidth: `calc(100vw - ${VIEWPORT_PADDING * 2}px)`,
+ maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
+ overflowY: "auto",
+ }}
+ >
+ {content}
+
+ );
+
+ return (
+ <>
+ {trigger}
+ {createPortal(panel, document.body)}
+ >
+ );
+}
diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx
index 073a2dbef..3339eb7fa 100644
--- a/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx
+++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx
@@ -1,8 +1,8 @@
/* @vitest-environment jsdom */
import React from "react";
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
import type { PrSummary } from "../../../shared/types";
import { LanePrBadge } from "./LanePrBadge";
import { LanePrBadgePopover } from "../lanes/LanePrBadgePopover";
@@ -50,6 +50,16 @@ function tag(overrides: Partial = {}): LaneTabPrTag {
};
}
+function openLanePrHoverCard(): HTMLElement {
+ const cluster = screen.getByTitle("2 pull requests on this lane");
+ const trigger = cluster.firstElementChild;
+ if (!(trigger instanceof HTMLElement)) throw new Error("lane PR hover trigger not found");
+ fireEvent.mouseEnter(trigger);
+ return screen.getByTestId("lane-pr-hover-card");
+}
+
+afterEach(cleanup);
+
describe("LanePrBadge", () => {
it("keeps a single PR as the compact chip", () => {
render();
@@ -78,6 +88,8 @@ describe("LanePrBadge", () => {
);
expect(screen.getByRole("button", { name: "Open 2 pull requests for this lane" })).toBeTruthy();
+ const hoverCard = openLanePrHoverCard();
+ expect(hoverCard.parentElement).toBe(document.body);
fireEvent.click(screen.getByTitle("Pull request #100 · Merged · Previous work"));
expect(onOpen).toHaveBeenCalledWith(previous);
@@ -94,6 +106,7 @@ describe("LanePrBadge", () => {
/>,
);
+ openLanePrHoverCard();
expect(screen.getByRole("img", { name: "CI failing; Review changes requested" })).toBeTruthy();
});
diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx
index 0d045c476..211a5a5ba 100644
--- a/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx
+++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx
@@ -7,9 +7,11 @@ import {
lanePrAttentionColor,
lanePrStateColor,
lanePrStateLabel,
+ pickPrimaryPr,
} from "../../lib/lanePrBadge";
import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge";
import { getPrCiDotColor, getPrReviewDotColor } from "../prs/shared/prVisuals";
+import { LanePrHoverCard } from "../lanes/LanePrHoverCard";
function StatusDot({ color, title }: { color: string; title?: string }) {
return (
@@ -63,17 +65,18 @@ export function LanePrBadge({
onOpenList?: () => void;
}) {
const allPrs = prs.length > 0 ? prs : [pr];
- const stackDescription = pr.stack
- ? `, position ${pr.stack.position} of ${pr.stack.size} in GitHub Stack #${pr.stack.number}`
+ const primaryPr = pickPrimaryPr(allPrs) ?? pr;
+ const stackDescription = primaryPr.stack
+ ? `, position ${primaryPr.stack.position} of ${primaryPr.stack.size} in GitHub Stack #${primaryPr.stack.number}`
: "";
- const open = (event: React.SyntheticEvent, target: PrSummary = pr) => {
+ const open = (event: React.SyntheticEvent, target: PrSummary = primaryPr) => {
event.stopPropagation();
onOpen(target);
};
if (allPrs.length === 1) {
- const color = lanePrStateColor(pr.state);
- const label = lanePrStateLabel(pr.state);
+ const color = lanePrStateColor(primaryPr.state);
+ const label = lanePrStateLabel(primaryPr.state);
return (
- #{pr.githubPrNumber}
-
+ #{primaryPr.githubPrNumber}
+
{label}
);
@@ -102,58 +105,15 @@ export function LanePrBadge({
const canOpenList = typeof onOpenList === "function";
return (
event.stopPropagation()}
- onMouseDown={(event) => event.stopPropagation()}
+ className="inline-flex shrink-0 items-center gap-1"
title={`${allPrs.length} pull requests on this lane`}
>
- open(event)}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- open(event);
- }
- }}
- className="inline-flex shrink-0 items-center gap-1 rounded-full border border-white/10 bg-white/[0.04] px-1.5 py-px text-[10px] font-medium leading-none text-muted-fg/70 transition-colors hover:bg-white/[0.09]"
- aria-label={`${prTitle(pr)}; ${allPrs.length - 1} other pull requests on this lane`}
- >
-
- #{pr.githubPrNumber}
-
- {lanePrStateLabel(pr.state)}
-
- {
- if (!onOpenList) return;
- event.stopPropagation();
- onOpenList();
- }}
- onKeyDown={(event) => {
- if (!onOpenList) return;
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- event.stopPropagation();
- onOpenList();
- }
- }}
- >
- +{allPrs.length - 1}
-
-
-
-
+
Pull requests · {allPrs.length}
@@ -199,8 +159,54 @@ export function LanePrBadge({
);
})}
+
+ )}
+ >
+ open(event)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ open(event);
+ }
+ }}
+ className="inline-flex shrink-0 items-center gap-1 rounded-full border border-white/10 bg-white/[0.04] px-1.5 py-px text-[10px] font-medium leading-none text-muted-fg/70 transition-colors hover:bg-white/[0.09]"
+ aria-label={`${prTitle(primaryPr)}; ${allPrs.length - 1} other pull requests on this lane`}
+ >
+
+ #{primaryPr.githubPrNumber}
+
+ {lanePrStateLabel(primaryPr.state)}
-
+ {
+ if (!onOpenList) return;
+ event.stopPropagation();
+ onOpenList();
+ }}
+ onKeyDown={(event) => {
+ if (!onOpenList) return;
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ event.stopPropagation();
+ onOpenList();
+ }
+ }}
+ >
+ +{allPrs.length - 1}
+
+
);
}
diff --git a/apps/desktop/src/renderer/lib/lanePrBadge.test.ts b/apps/desktop/src/renderer/lib/lanePrBadge.test.ts
index 18e290941..decd1ec90 100644
--- a/apps/desktop/src/renderer/lib/lanePrBadge.test.ts
+++ b/apps/desktop/src/renderer/lib/lanePrBadge.test.ts
@@ -100,7 +100,7 @@ describe("selectPrimaryLanePr", () => {
updatedAt: "2026-07-02T00:00:00.000Z",
};
- it("lets an actionable failing historical PR win over a healthy current PR", () => {
+ it("keeps the newest open PR on the collapsed lane badge", () => {
const healthyCurrent = {
...base,
id: "current",
@@ -110,6 +110,7 @@ describe("selectPrimaryLanePr", () => {
headBranch: "current",
checksStatus: "passing" as const,
reviewStatus: "approved" as const,
+ updatedAt: "2026-07-02T00:00:00.000Z",
};
const failingPrevious = {
...base,
@@ -120,10 +121,38 @@ describe("selectPrimaryLanePr", () => {
headBranch: "old-branch",
checksStatus: "failing" as const,
reviewStatus: "approved" as const,
+ updatedAt: "2026-07-03T00:00:00.000Z",
};
expect(selectPrimaryLanePr(lane, [healthyCurrent, failingPrevious])?.id).toBe("previous");
});
+
+ it("uses the latest activity when multiple terminal PRs remain", () => {
+ const olderMerged = {
+ ...base,
+ id: "older-merged",
+ githubPrNumber: 8,
+ title: "Older merged",
+ state: "merged" as const,
+ headBranch: "older",
+ checksStatus: "passing" as const,
+ reviewStatus: "approved" as const,
+ updatedAt: "2026-07-02T00:00:00.000Z",
+ };
+ const newerMerged = {
+ ...base,
+ id: "newer-merged",
+ githubPrNumber: 7,
+ title: "Newer merged",
+ state: "merged" as const,
+ headBranch: "newer",
+ checksStatus: "passing" as const,
+ reviewStatus: "approved" as const,
+ updatedAt: "2026-07-04T00:00:00.000Z",
+ };
+
+ expect(selectPrimaryLanePr(lane, [olderMerged, newerMerged])?.id).toBe("newer-merged");
+ });
});
describe("lane PR attention", () => {
diff --git a/apps/desktop/src/renderer/lib/lanePrBadge.ts b/apps/desktop/src/renderer/lib/lanePrBadge.ts
index 66c9ab90b..7772d40f6 100644
--- a/apps/desktop/src/renderer/lib/lanePrBadge.ts
+++ b/apps/desktop/src/renderer/lib/lanePrBadge.ts
@@ -21,13 +21,17 @@ export function primaryPrStateRank(state: PrState): number {
}
}
-type PrimaryPrComparable = Pick;
+type PrimaryPrComparable = {
+ state: PrSummary["state"];
+ updatedAt?: string | null;
+ githubPrNumber: number;
+};
function comparePrimaryPr(a: PrimaryPrComparable, b: PrimaryPrComparable): number {
const byRank = primaryPrStateRank(a.state) - primaryPrStateRank(b.state);
if (byRank !== 0) return byRank;
- const aUpdated = Date.parse(a.updatedAt);
- const bUpdated = Date.parse(b.updatedAt);
+ const aUpdated = Date.parse(a.updatedAt ?? "");
+ const bUpdated = Date.parse(b.updatedAt ?? "");
if (Number.isFinite(aUpdated) && Number.isFinite(bUpdated) && aUpdated !== bUpdated) {
return bUpdated - aUpdated;
}
@@ -40,7 +44,7 @@ function comparePrimaryPr(a: PrimaryPrComparable, b: PrimaryPrComparable): numbe
* number) wins. Returns null for an empty list. Pure — the caller pre-filters
* to a lane's PRs.
*/
-export function pickPrimaryPr(prs: T[]): T | null {
+export function pickPrimaryPr(prs: readonly T[]): T | null {
let best: T | null = null;
for (const pr of prs) {
if (best === null || comparePrimaryPr(pr, best) < 0) best = pr;
@@ -49,9 +53,10 @@ export function pickPrimaryPr(prs: T[]): T | null
}
/**
- * Choose the lane badge's attention target. A failing or blocked historical PR
- * must be able to win over a healthy current PR so the collapsed chip never
- * hides the lane's most actionable problem.
+ * Choose the PR represented by a lane badge. The collapsed card should point
+ * to the newest open work first; when no work is open, the latest draft or
+ * terminal PR activity is the useful fallback. Attention remains an aggregate
+ * signal on the badge, but it must not replace the user's current PR context.
*/
export function selectPrimaryLanePr(
lane: Pick,
@@ -64,17 +69,7 @@ export function selectPrimaryLanePr(
const candidates = lanePrs.length > 0
? lanePrs
: prs.filter((pr) => pr.laneId === lane.id && !pr.detached);
- let best: PrSummary | null = null;
- for (const pr of candidates) {
- if (!best || compareLanePrimaryPr(pr, best) < 0) best = pr;
- }
- return best;
-}
-
-function compareLanePrimaryPr(a: PrSummary, b: PrSummary): number {
- const byAttention = lanePrAttentionRank(b) - lanePrAttentionRank(a);
- if (byAttention !== 0) return byAttention;
- return comparePrimaryPr(a, b);
+ return pickPrimaryPr(candidates);
}
export function lanePrsForLane(
diff --git a/apps/desktop/src/shared/composerTriggers.test.ts b/apps/desktop/src/shared/composerTriggers.test.ts
index 9389bf051..6b477e44a 100644
--- a/apps/desktop/src/shared/composerTriggers.test.ts
+++ b/apps/desktop/src/shared/composerTriggers.test.ts
@@ -51,6 +51,19 @@ describe("detectComposerTrigger", () => {
expect(detectComposerTrigger(text, text.length)).toEqual({ type: "at", query: "src/foo.ts", start: 4 });
});
+ it("keeps an at trigger open across spaces for multi-word chat names", () => {
+ expect(detectComposerTrigger("@a b c", 6)).toEqual({ type: "at", query: "a b c", start: 0 });
+ // A trailing space is still part of the in-progress query. The menu trims
+ // it for searching, so cached suggestions remain visible while the next
+ // word is being typed.
+ expect(detectComposerTrigger("@a ", 3)).toEqual({ type: "at", query: "a ", start: 0 });
+ });
+
+ it("does not let an at query cross a newline or another at sign", () => {
+ expect(detectComposerTrigger("@a\nb", 4)).toBeNull();
+ expect(detectComposerTrigger("@a@b", 4)).toBeNull();
+ });
+
it("does not trigger on emails", () => {
const text = "mail user@doma";
expect(detectComposerTrigger(text, text.length)).toBeNull();
diff --git a/apps/desktop/src/shared/composerTriggers.ts b/apps/desktop/src/shared/composerTriggers.ts
index 4d7398815..932578669 100644
--- a/apps/desktop/src/shared/composerTriggers.ts
+++ b/apps/desktop/src/shared/composerTriggers.ts
@@ -1,7 +1,7 @@
// Cursor-relative trigger detection for the prompt composers (desktop chat
// composer, ade-code TUI prompt, mirrored on iOS). A trigger is an in-progress
-// `/command` or `@file` token that ends exactly at the cursor, so suggestion
-// menus can open anywhere in the draft — not just at position 0.
+// `/command` or `@` query that ends exactly at the cursor, so suggestion menus
+// can open anywhere in the draft — not just at position 0.
export type ComposerTriggerType = "slash" | "at";
@@ -14,11 +14,12 @@ export type ComposerTrigger = {
};
// Both triggers must sit at a word boundary (start of text or after
-// whitespace) and their token must run unbroken to the cursor. The slash token
-// is a command name only: no whitespace and no `/` inside, so paths like
+// whitespace) and their token must run to the cursor. The slash token is a
+// command name only: no whitespace and no `/` inside, so paths like
// `/usr/bin` and fractions like `3/4` never trigger. The `@` token allows `/`
-// (file paths) but not another `@`, so emails never trigger.
-const AT_TRIGGER_RE = /(?:^|\s)(@([^\s@]*))$/;
+// (file paths) and spaces (chat names are commonly multi-word), but not
+// another `@` or a newline, so emails and cross-line prose never trigger.
+const AT_TRIGGER_RE = /(?:^|[ \t\r\n])(@([^@\r\n]*))$/;
const SLASH_TRIGGER_RE = /(?:^|\s)(\/([^\s/]*))$/;
export function detectComposerTrigger(text: string, cursorPos: number): ComposerTrigger | null {
diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
index 32829ea9d..b8cba1131 100644
--- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
+++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
@@ -152,14 +152,16 @@ struct WorkComposerTriggerMatch: Equatable {
}
/// Pure, cursor-relative trigger detection shared by the composer text view.
+/// Applied to the substring before the cursor. When both match (rare), the one
+/// whose trigger char sits closest to the cursor wins.
/// Mirrors the desktop/TUI regexes exactly:
/// slash — `(?:^|\s)/([^\s/]*)$`
-/// at — `(?:^|\s)@([^\s@]*)$`
-/// applied to the substring before the cursor. When both match (rare), the one
-/// whose trigger char sits closest to the cursor wins.
+/// at — `(?:^|[ \t\r\n])@([^@\r\n]*)$`
+/// The `@` query may contain spaces for multi-word entity names, but it stops
+/// at a newline or another `@`.
enum WorkComposerTriggerDetector {
private static let slashRegex = try! NSRegularExpression(pattern: "(?:^|\\s)/([^\\s/]*)$")
- private static let atRegex = try! NSRegularExpression(pattern: "(?:^|\\s)@([^\\s@]*)$")
+ private static let atRegex = try! NSRegularExpression(pattern: "(?:^|[ \\t\\r\\n])@([^@\\r\\n]*)$")
static func detect(in text: NSString, cursor: Int) -> WorkComposerTriggerMatch? {
guard cursor >= 0, cursor <= text.length else { return nil }
diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
index 6819fb1ab..3147ff0d8 100644
--- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
+++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
@@ -4,7 +4,8 @@ import SwiftUI
@testable import ADE
/// Pure-logic coverage for the cursor-relative trigger detector that mirrors the
-/// shared desktop/TUI regexes (`(?:^|\s)/([^\s/]*)$` and `(?:^|\s)@([^\s@]*)$`).
+/// shared desktop/TUI regexes (`(?:^|\s)/([^\s/]*)$` and
+/// `(?:^|[ \t\r\n])@([^@\r\n]*)$`).
/// Locks in the boundary rules that keep paths, fractions, and emails from
/// opening the suggestion strip, plus the closest-to-cursor tie-break.
final class WorkComposerTriggerDetectorTests: XCTestCase {
@@ -64,6 +65,18 @@ final class WorkComposerTriggerDetectorTests: XCTestCase {
XCTAssertEqual(match?.query, "a/b/c")
}
+ func testAtSupportsMultiWordQueries() {
+ let match = detect("@a b c")
+ XCTAssertEqual(match?.kind, .at)
+ XCTAssertEqual(match?.query, "a b c")
+ XCTAssertEqual(match?.range, NSRange(location: 0, length: 6))
+ }
+
+ func testAtDoesNotCrossNewlinesOrAnotherAtSign() {
+ XCTAssertNil(detect("@a\nb"))
+ XCTAssertNil(detect("@a@b"))
+ }
+
func testEmailDoesNotTrigger() {
// The `@` is glued to a preceding non-space char, so emails never trigger.
XCTAssertNil(detect("ping foo@bar"))
diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md
index fdb3fa7ea..46056e39c 100644
--- a/docs/features/chat/composer-and-ui.md
+++ b/docs/features/chat/composer-and-ui.md
@@ -31,8 +31,8 @@ subagents, computer use). The pane derives all visible state from the
| `ChatSurfaceShell.tsx` | Floating chat header, body, footer layout. Backdrop-blur glass-morphism styling. |
| `ChatComposerShell.tsx` | Input container chrome reused by the composer. |
| `ChatAttachmentTray.tsx` | Inline file/image attachment tray inside the composer. Image attachments render an inline thumbnail, open a full-size lightbox on click, and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage` so the user can paste them into another app. Pasted images can pass a seeded preview URL from the composer while the temp file is being saved; tray-only image refs fall back to `window.ade.app.getImageDataUrl`. Non-image attachments fall back to the file glyph. |
-| `ChatCommandMenu.tsx` | Popover for slash commands and the sectioned `@` menu: Files plus Chats / Lanes / Terminals entity mentions. 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; 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: the file index returns shallowest-first results and the mention action returns recency-ranked entities, so the menu is never empty before typing. 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:` chip (see `shared/chatMentions.ts`). |
-| `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` / `@file` token ending at the cursor at any position; `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. |
+| `ChatCommandMenu.tsx` | Popover for slash commands and the sectioned `@` menu: Files plus Chats / Lanes / Terminals entity mentions. 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; 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: the file index returns shallowest-first results and the mention action returns recency-ranked entities, so the menu is never empty before typing. 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 the selected entity title (see `shared/chatMentions.ts`). |
+| `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 `@`; `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. |
| `apps/desktop/src/shared/smartLinks.ts` | Cross-client URL catalog and deterministic fallback labels. Recognizes GitHub PR/issue/repo/commit/action-run links, Linear issues, `ade://` deeplinks, and generic HTTP(S) pages; trims sentence punctuation, caps each draft at 12 matches, and keeps the canonical URL separate from optional title/favicon metadata. Desktop, hosted web, and ADE Code import this contract; iOS mirrors it in `WorkSmartLinkDetector`. |
| `apps/desktop/src/main/services/chat/smartLinkPreviewService.ts` | Runtime-owned best-effort metadata resolver. GitHub and Linear titles use configured provider services; generic pages use bounded public-network HTML/favicon reads with DNS pinning and SSRF checks. Generic previews cache at most 256 public entries for 30 minutes (five minutes for metadata misses); credential-backed provider results are never stored in that process-global cache. Any error returns the deterministic local preview rather than blocking composition. |
| `ChatTasksPanel.tsx` | Todo list rendered from `todo_update` events. |
@@ -1513,7 +1513,9 @@ These modules are pure and unit-testable:
word boundary before `/` and allow no whitespace or `/` inside, so
paths (`/usr/bin`), URLs, and fractions never open the menu. Don't
reintroduce `startsWith("/")` gates — that regresses mid-sentence
- commands. In the rich editor, detection runs on the DOM text run
+ commands. `@` queries may contain spaces so multi-word chat names remain
+ searchable; the trigger still stops at a newline or another `@`. In the
+ rich editor, detection runs on the DOM text run
around the caret (`getRichTriggerContext`), NOT on serialized-draft
offsets: serialization collapses whitespace and flattens chips, so
serialized indices cannot be mapped back onto DOM positions.
diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md
index 57e362067..ffc3d92f4 100644
--- a/docs/features/lanes/README.md
+++ b/docs/features/lanes/README.md
@@ -77,8 +77,9 @@ Renderer components:
|------|---------------|
| `renderer/components/app/App.tsx` | Project tab host and route keep-alive shell. Keeps the Work surface mounted after first visit and now does the same for `/lanes`, parking the inactive Lanes surface with `inert` / `aria-hidden` instead of unmounting it. Parked route and project surfaces also set `data-ade-animation-state="paused"`, which lets the global renderer stylesheet stop hidden CSS animations until the surface is active again. Surfaces are keyed by runtime binding (`local:` or the remote binding key) so local and remote views of the same root do not share lane/work state. During cold project switches it renders a transition veil over the old project surface until the target project hydrates. |
| `renderer/components/app/toast/{toastStore.ts,ToastStack.tsx,useLaneEventToasts.ts}` | Shared renderer toast primitive mounted from `AppShell`. `useLaneEventToasts` subscribes to `lanes.onLifecycleEvent` and `lanes.rebaseSubscribe`, turning lane-created/archive/delete and final automated rebase outcomes into compact global notices; created-lane toasts include a `View` action that routes to `/lanes?laneId=...&focus=single`. |
-| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Create-lane state lives in `CreateLaneDialogHost`; `LanesPage` owns only open/prefill routing, blocks forced close while the host is busy, and focuses the new lane after the host refreshes the lane list while the dialog stays open for setup progress. The lane filter, pinned lane ids, and expanded lane id live in the active project's `WorkProjectViewState`, not component-local state, so route/tab/project remounts restore the correct project's view. Each lane row renders the lane's retained live PR set: the current-branch PR is `active`, older PRs from previous branches are `previous`, and two or more rows collapse to a primary state-aware tag plus a `+N` counter. Hover/focus reveals every PR with its own state, CI, review, and previous-branch signals; the counter opens the lane-filtered PR workspace. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Those forced refreshes are marked `automaticRefresh` — nobody pressed anything — so they stay inside the service's GitHub failure ladder and a rate-limited GitHub is not re-asked on every branch-signature change (see [pull-requests](../pull-requests/README.md#github-read-failure-ladder)); `refreshLaneGithubPrTags` returns `false` when it fell back to the last usable snapshot, which is what the retry logic keys on. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents; within each dependency-safe batch the page dispatches up to two lane deletes at a time and records per-lane failures, and a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. |
-| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR role derivation (`active` vs `previous`), multi-PR ordering and attention selection, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchWithConcurrency` for limited parallel teardown inside each dependency-safe batch. |
+| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Create-lane state lives in `CreateLaneDialogHost`; `LanesPage` owns only open/prefill routing, blocks forced close while the host is busy, and focuses the new lane after the host refreshes the lane list while the dialog stays open for setup progress. The lane filter, pinned lane ids, and expanded lane id live in the active project's `WorkProjectViewState`, not component-local state, so route/tab/project remounts restore the correct project's view. Each lane row renders the lane's retained live PR set: the current-branch PR is `active`, older PRs from previous branches are `previous`, and two or more rows collapse to the newest open/draft PR (or newest terminal activity) plus a `+N` counter. Hover/focus reveals every PR with its own state, CI, review, and previous-branch signals; the fixed, viewport-clamped hover card is rendered outside row overflow, and the counter opens the lane-filtered PR workspace. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Those forced refreshes are marked `automaticRefresh` — nobody pressed anything — so they stay inside the service's GitHub failure ladder and a rate-limited GitHub is not re-asked on every branch-signature change (see [pull-requests](../pull-requests/README.md#github-read-failure-ladder)); `refreshLaneGithubPrTags` returns `false` when it fell back to the last usable snapshot, which is what the retry logic keys on. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents; within each dependency-safe batch the page dispatches up to two lane deletes at a time and records per-lane failures, and a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. |
+| `renderer/components/lanes/LanePrBadgePopover.tsx`, `LanePrHoverCard.tsx` | Multi-PR lane badge and its shared fixed-position portal. The primary chip chooses the newest open/draft PR before falling back to recent terminal activity; the complete list is clamped to the viewport and dismissed on scroll/resize so row/card overflow cannot hide it. |
+| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR role derivation (`active` vs `previous`), multi-PR ordering, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchWithConcurrency` for limited parallel teardown inside each dependency-safe batch. |
| `renderer/hooks/useLaneListInvalidation.ts` | Shared lane-list invalidation hook used by Lanes, Graph, and PRs. It subscribes to `window.ade.lanes.onLifecycleEvent`, clears renderer read coalescing immediately, debounces a decorated `refreshLanes` call, runs one delayed follow-up refresh to cover daemon/write-to-read races, and self-heals stale visible lists on focus/visibility without polling. Hidden lifecycle events are replayed when the surface becomes visible. |
| `renderer/lib/laneReadCache.ts` | Renderer-side in-flight coalescing for lane list/snapshot/keybinding reads. Lane lifecycle invalidation clears lane list/snapshot requests and bumps a generation token so a newly requested read never reuses an older in-flight lane snapshot. |
| `renderer/state/appStore.ts` | Shared renderer project/lane state. Stores `laneDeleteProgressByLaneId` so in-flight lane deletion UI survives local `LanesPage` remounts and project metadata updates; the map clears only when the project root changes or the project is closed/reset. `WorkProjectViewState` also owns the per-project Lanes filter, pinned ids, and expanded id. Warm project-tab switches restore cached lanes/snapshots, lane selection, focused session, and loading state before the backend round trip finishes, and cache pruning retains Work/lane/session state for all open project tabs in addition to the active and recent projects. `refreshLanes` discards stale responses with a version token so older lane-list reads cannot overwrite a newer refresh, and it does not prune persisted lane scopes from the empty transitional list seen during project switches or remote reconnects. |
diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md
index bffc77896..599dd2664 100644
--- a/docs/features/terminals-and-sessions/README.md
+++ b/docs/features/terminals-and-sessions/README.md
@@ -1267,12 +1267,16 @@ Renderer surfaces:
`ade.agentChat.delete`. Fixed-position menus measure and clamp to the renderer
viewport.
- `apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx`,
+ `apps/desktop/src/renderer/components/lanes/LanePrHoverCard.tsx`,
`apps/desktop/src/renderer/lib/lanePrBadge.ts`,
`LaneActionsSubmenu.tsx`, and
`apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx` — the shared
compact PR state badge, its selection/presentation/navigation helpers, the
singleton session row's lane submenu, and the pointer-safe/keyboard-accessible
- submenu primitive. The badge is presentation-only; where it opens is decided
+ submenu primitive. Multi-PR badges choose the newest open/draft PR (or newest
+ terminal activity) for the collapsed chip and render their detail list in a
+ viewport-clamped portal, so session-card overflow cannot hide it. The badge
+ is presentation-only; where it opens is decided
once by `openLanePr`, which sends a PR on the machine you are bound to into
the PRs tab and a foreign one to GitHub, since a PR id resolves only on the
machine that owns it. `LaneActionsSubmenu` renders the same
diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md
index dc9fbe4ab..df3cf55b9 100644
--- a/docs/features/terminals-and-sessions/ui-surfaces.md
+++ b/docs/features/terminals-and-sessions/ui-surfaces.md
@@ -278,7 +278,10 @@ The full card is one full-bleed row with three lines:
and stays untruncated. A card whose lane lives on this machine deep-links to
the PR in ADE; a card on another machine badges from that machine's own PR
rows and opens the PR on GitHub, because the PRs tab can only resolve a PR
- id on the machine the project tab is bound to. While the owning lane is
+ id on the machine the project tab is bound to. When a lane has multiple PRs,
+ the badge represents the newest open/draft PR, or the newest terminal
+ activity when no PR is active, and its `+N` list is rendered in a
+ viewport-clamped portal so the session card cannot clip the details. While the owning lane is
mid background AI naming, every visible lane-label position (singleton row,
hover detail, or grouped header) uses the shared animated `Naming lane…`
placeholder; the persisted deterministic fallback stays hidden unless
From f8ef13debfcea8f266d5b9083b7f2c23e42da1d7 Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:59:29 -0400
Subject: [PATCH 02/17] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20addre?=
=?UTF-8?q?ss=20review=20findings?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/ade-cli/src/tuiClient/app.tsx | 38 ++++++-----
.../chat/AgentChatComposer.test.tsx | 25 ++++++++
.../components/chat/AgentChatComposer.tsx | 49 ++++++++++++---
.../components/lanes/LanePrHoverCard.tsx | 63 +++++++++++++++++--
.../components/terminals/LanePrBadge.test.tsx | 40 +++++++++++-
.../src/renderer/lib/lanePrBadge.test.ts | 12 +++-
apps/desktop/src/renderer/lib/lanePrBadge.ts | 5 +-
apps/desktop/src/shared/chatMentions.test.ts | 13 ++++
apps/desktop/src/shared/chatMentions.ts | 4 ++
.../src/shared/composerTriggers.test.ts | 18 ++++++
apps/desktop/src/shared/composerTriggers.ts | 28 +++++++++
.../Work/WorkComposerTypedTriggers.swift | 42 ++++++++++++-
.../WorkComposerTriggerDetectorTests.swift | 19 ++++++
docs/features/chat/composer-and-ui.md | 12 ++--
docs/features/lanes/README.md | 2 +-
.../features/terminals-and-sessions/README.md | 4 +-
.../terminals-and-sessions/ui-surfaces.md | 4 +-
17 files changed, 336 insertions(+), 42 deletions(-)
diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx
index 7328fca8a..3775a299e 100644
--- a/apps/ade-cli/src/tuiClient/app.tsx
+++ b/apps/ade-cli/src/tuiClient/app.tsx
@@ -15,6 +15,7 @@ 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 {
+ composerTriggerForSelection,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
findConfirmedComposerTokens,
@@ -7339,7 +7340,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
return;
}
let cancelled = false;
- const query = range.query.toLowerCase();
+ const query = range.query.trim().toLowerCase();
+ const matchesMentionQuery = (suggestion: MentionSuggestion): boolean => {
+ if (!query) return true;
+ const label = suggestion.label.toLowerCase();
+ return (
+ label.includes(query)
+ || query.startsWith(`${label} `)
+ || suggestion.insertText.toLowerCase().includes(query)
+ || Boolean(suggestion.detail?.toLowerCase().includes(query))
+ );
+ };
const localSuggestions = (): MentionSuggestion[] => [
...lanes.map((lane) => ({
kind: "lane" as const,
@@ -7353,20 +7364,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;
@@ -7452,7 +7453,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 || loweredTitle.includes(query) || query.startsWith(`${loweredTitle} `) || number.includes(query);
})
.slice(0, 5)
.map((pr) => {
@@ -12331,8 +12333,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,
+ );
const next = replaceComposerTriggerSpan(prompt, trigger, `${suggestion.insertText} `);
setPromptValue(next.text, next.caret);
setSelectedMentions((prev) => {
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
index 9d9db1766..94bb6d8c1 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
@@ -714,6 +714,31 @@ describe("AgentChatComposer", () => {
expect(view.container.querySelector("[aria-hidden]")?.textContent).not.toContain("@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 c9bb77b7d..47140ad11 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
@@ -41,6 +41,7 @@ import type {
} from "../../../shared/types/orchestration";
import { getModelById, modelSupportsFastMode, type ProviderFamily } from "../../../shared/modelRegistry";
import {
+ composerTriggerForSelection,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
findConfirmedComposerTokens,
@@ -2723,7 +2724,7 @@ export function AgentChatComposer({
// 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();
@@ -2751,8 +2752,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;
@@ -2767,9 +2771,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 };
}, []);
@@ -2778,7 +2795,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;
@@ -2789,7 +2806,14 @@ 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 ?? "")
+ : detectedContext.trigger;
+ const context = trigger.query === detectedContext.trigger.query
+ ? detectedContext
+ : getRichTriggerContext(trigger.query);
if (!context) return false;
selection?.removeAllRanges();
selection?.addRange(context.range);
@@ -3883,11 +3907,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);
+ const next = replaceComposerTriggerSpan(draft, trigger, `@${item.path} `);
onDraftChange(next.text);
restoreTextareaCaret(next.caret);
}
@@ -3902,11 +3931,13 @@ export function AgentChatComposer({
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);
+ const next = replaceComposerTriggerSpan(draft, trigger, `${token} `);
onDraftChange(next.text);
restoreTextareaCaret(next.caret);
}
diff --git a/apps/desktop/src/renderer/components/lanes/LanePrHoverCard.tsx b/apps/desktop/src/renderer/components/lanes/LanePrHoverCard.tsx
index 8367c9e97..34bd49032 100644
--- a/apps/desktop/src/renderer/components/lanes/LanePrHoverCard.tsx
+++ b/apps/desktop/src/renderer/components/lanes/LanePrHoverCard.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from "react";
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useClampedFixedPosition, type FixedAnchor } from "../../hooks/useClampedFixedPosition";
@@ -8,8 +8,9 @@ const VIEWPORT_PADDING = 8;
/**
* PR detail lists live outside their lane/session card so card overflow cannot
- * clip them. The anchor is measured only when the card opens; scroll/resize
- * dismisses it instead of installing a hot-path reposition listener.
+ * clip them. The anchor is measured only when the card opens; viewport
+ * scroll/resize dismisses it instead of installing a hot-path reposition
+ * listener. Internal panel scrolling remains available for long PR lists.
*/
export function LanePrHoverCard({
children,
@@ -26,6 +27,7 @@ export function LanePrHoverCard({
}) {
const triggerRef = useRef(null);
const closeTimerRef = useRef | null>(null);
+ const focusPanelOnOpenRef = useRef(false);
const [anchor, setAnchor] = useState(null);
const { ref: panelRef, position } = useClampedFixedPosition(anchor, label);
@@ -37,6 +39,7 @@ export function LanePrHoverCard({
const close = useCallback(() => {
cancelClose();
+ focusPanelOnOpenRef.current = false;
setAnchor(null);
}, [cancelClose]);
@@ -60,6 +63,25 @@ export function LanePrHoverCard({
setAnchor({ x: rect.left, y: rect.bottom + GAP });
}, [cancelClose]);
+ const focusTrigger = useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+ const focusable = trigger.querySelector(
+ "button, a[href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])",
+ );
+ focusable?.focus();
+ }, []);
+
+ const openFromKeyboard = useCallback(() => {
+ focusPanelOnOpenRef.current = true;
+ open();
+ }, [open]);
+
+ const closeAndRestoreFocus = useCallback(() => {
+ close();
+ requestAnimationFrame(focusTrigger);
+ }, [close, focusTrigger]);
+
const isWithinCard = useCallback((target: EventTarget | null): boolean => {
return target instanceof Node && Boolean(
triggerRef.current?.contains(target) || panelRef.current?.contains(target),
@@ -68,14 +90,28 @@ export function LanePrHoverCard({
useEffect(() => {
if (!anchor) return undefined;
- const closeOnViewportChange = () => close();
+ const closeOnViewportChange = (event: Event) => {
+ if (event.type === "scroll" && event.target instanceof Node && panelRef.current?.contains(event.target)) {
+ return;
+ }
+ close();
+ };
window.addEventListener("scroll", closeOnViewportChange, true);
window.addEventListener("resize", closeOnViewportChange);
return () => {
window.removeEventListener("scroll", closeOnViewportChange, true);
window.removeEventListener("resize", closeOnViewportChange);
};
- }, [anchor, close]);
+ }, [anchor, close, panelRef]);
+
+ useLayoutEffect(() => {
+ if (!anchor || !focusPanelOnOpenRef.current || !panelRef.current) return;
+ focusPanelOnOpenRef.current = false;
+ const firstInteractive = panelRef.current.querySelector(
+ "button, a[href], input, select, textarea, [role=\"button\"], [tabindex]:not([tabindex=\"-1\"])",
+ );
+ firstInteractive?.focus();
+ }, [anchor, panelRef]);
useEffect(() => () => {
if (closeTimerRef.current != null) clearTimeout(closeTimerRef.current);
@@ -94,6 +130,17 @@ export function LanePrHoverCard({
onMouseEnter={open}
onMouseLeave={scheduleClose}
onFocus={open}
+ onKeyDown={(event) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ event.stopPropagation();
+ openFromKeyboard();
+ } else if (event.key === "Escape" && anchor) {
+ event.preventDefault();
+ event.stopPropagation();
+ closeAndRestoreFocus();
+ }
+ }}
onBlur={(event) => {
if (!isWithinCard(event.relatedTarget)) scheduleClose();
}}
@@ -117,6 +164,12 @@ export function LanePrHoverCard({
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onFocus={cancelClose}
+ onKeyDown={(event) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ event.stopPropagation();
+ closeAndRestoreFocus();
+ }}
onBlur={(event) => {
if (!isWithinCard(event.relatedTarget)) scheduleClose();
}}
diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx
index 3339eb7fa..57ab8f014 100644
--- a/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx
+++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import React from "react";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PrSummary } from "../../../shared/types";
import { LanePrBadge } from "./LanePrBadge";
@@ -110,6 +110,44 @@ describe("LanePrBadge", () => {
expect(screen.getByRole("img", { name: "CI failing; Review changes requested" })).toBeTruthy();
});
+ it("keeps the lane PR hover card open while its own panel scrolls", () => {
+ render(
+ ,
+ );
+
+ const hoverCard = openLanePrHoverCard();
+ fireEvent.scroll(hoverCard);
+
+ expect(screen.getByTestId("lane-pr-hover-card")).toBe(hoverCard);
+ });
+
+ it("moves focus into the multi-PR hover card from the trigger", async () => {
+ render(
+ ,
+ );
+
+ const trigger = screen.getByRole("button", { name: /Pull request #101/ });
+ trigger.focus();
+ fireEvent.keyDown(trigger, { key: "ArrowDown" });
+
+ const hoverCard = await screen.findByTestId("lane-pr-hover-card");
+ const firstRow = hoverCard.querySelector('[role="button"]');
+ expect(firstRow).not.toBeNull();
+ await waitFor(() => expect(document.activeElement).toBe(firstRow));
+
+ fireEvent.keyDown(firstRow!, { key: "Escape" });
+ await waitFor(() => expect(screen.queryByTestId("lane-pr-hover-card")).toBeNull());
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
+ });
+
it("keeps a popover count non-interactive without a list handler", () => {
render(
{
],
expected: "merged",
},
+ {
+ name: "valid activity beats a missing timestamp",
+ prs: [
+ pr("missing", "open", null, 99),
+ pr("known", "open", "2026-07-03T00:00:00Z", 1),
+ ],
+ expected: "known",
+ },
];
for (const { name, prs, expected } of cases) {
diff --git a/apps/desktop/src/renderer/lib/lanePrBadge.ts b/apps/desktop/src/renderer/lib/lanePrBadge.ts
index 7772d40f6..315a33678 100644
--- a/apps/desktop/src/renderer/lib/lanePrBadge.ts
+++ b/apps/desktop/src/renderer/lib/lanePrBadge.ts
@@ -32,7 +32,10 @@ function comparePrimaryPr(a: PrimaryPrComparable, b: PrimaryPrComparable): numbe
if (byRank !== 0) return byRank;
const aUpdated = Date.parse(a.updatedAt ?? "");
const bUpdated = Date.parse(b.updatedAt ?? "");
- if (Number.isFinite(aUpdated) && Number.isFinite(bUpdated) && aUpdated !== bUpdated) {
+ const aHasUpdated = Number.isFinite(aUpdated);
+ const bHasUpdated = Number.isFinite(bUpdated);
+ if (aHasUpdated !== bHasUpdated) return aHasUpdated ? -1 : 1;
+ if (aHasUpdated && aUpdated !== bUpdated) {
return bUpdated - aUpdated;
}
return b.githubPrNumber - a.githubPrNumber;
diff --git a/apps/desktop/src/shared/chatMentions.test.ts b/apps/desktop/src/shared/chatMentions.test.ts
index 6798d14ba..185ead212 100644
--- a/apps/desktop/src/shared/chatMentions.test.ts
+++ b/apps/desktop/src/shared/chatMentions.test.ts
@@ -257,6 +257,19 @@ describe("chat mention ranking", () => {
expect(ranked.map((r) => r.id)).toEqual(["exact", "prefix", "sub"]);
});
+ it("keeps a title match when prose follows the mention", () => {
+ const ranked = rankChatMentionSuggestions(
+ [
+ { id: "short", title: "a b c", lastActivityAt: 10 },
+ { id: "other", title: "unrelated", lastActivityAt: 100 },
+ ],
+ "a b c about this",
+ 10,
+ );
+
+ expect(ranked.map((r) => r.id)).toEqual(["short"]);
+ });
+
it("honors the per-kind cap and is stable for equal rows", () => {
const ties = [
{ id: "z", title: "same", lastActivityAt: 5 },
diff --git a/apps/desktop/src/shared/chatMentions.ts b/apps/desktop/src/shared/chatMentions.ts
index a4d01652d..200faa017 100644
--- a/apps/desktop/src/shared/chatMentions.ts
+++ b/apps/desktop/src/shared/chatMentions.ts
@@ -251,6 +251,10 @@ function scoreChatMentionMatch(
if (!loweredQuery.length) return 0;
const target = haystack.toLowerCase();
if (target === loweredQuery) return 0;
+ // Once a title is an exact prefix, keep it visible while the user continues
+ // ordinary prose after the mention. Exact longer titles still win above this
+ // fallback, so a real multi-word title is selected before a shorter prefix.
+ if (loweredQuery.startsWith(`${target} `)) return 1;
if (target.startsWith(loweredQuery)) return 1;
if (target.includes(loweredQuery)) return 2;
// Subsequence fallback: every query char appears in order.
diff --git a/apps/desktop/src/shared/composerTriggers.test.ts b/apps/desktop/src/shared/composerTriggers.test.ts
index 6b477e44a..9a7a27ff0 100644
--- a/apps/desktop/src/shared/composerTriggers.test.ts
+++ b/apps/desktop/src/shared/composerTriggers.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ composerTriggerForSelection,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
findConfirmedComposerTokens,
@@ -91,6 +92,23 @@ describe("detectComposerTrigger", () => {
});
describe("replaceComposerTriggerSpan", () => {
+ it("keeps prose after a selected mention prefix", () => {
+ const trigger = detectComposerTrigger("ask @a b c about this", 20)!;
+ const selected = composerTriggerForSelection(trigger, "a b c");
+
+ expect(selected.query).toBe("a b c ");
+ expect(replaceComposerTriggerSpan("ask @a b c about this", selected, "@chat:chat-1 ")).toEqual({
+ text: "ask @chat:chat-1 about this",
+ caret: 17,
+ });
+ });
+
+ it("does not shorten a partial or non-prefix selection", () => {
+ const trigger = detectComposerTrigger("@abc", 4)!;
+ expect(composerTriggerForSelection(trigger, "a b c")).toEqual(trigger);
+ expect(composerTriggerForSelection(trigger, "other")).toEqual(trigger);
+ });
+
it("replaces exactly the trigger span mid-sentence", () => {
const text = "fix @src/f then run /te tomorrow";
const trigger = { start: 20, query: "te" };
diff --git a/apps/desktop/src/shared/composerTriggers.ts b/apps/desktop/src/shared/composerTriggers.ts
index 932578669..5746dfd68 100644
--- a/apps/desktop/src/shared/composerTriggers.ts
+++ b/apps/desktop/src/shared/composerTriggers.ts
@@ -37,6 +37,34 @@ export function detectComposerTrigger(text: string, cursorPos: number): Composer
return { type: "slash", query: slash![2] ?? "", start: slashStart };
}
+/**
+ * Narrow an @ trigger to the selected item's leading label when the user has
+ * continued typing prose after it. The menu can keep a prefix suggestion
+ * visible while the query grows, but replacing the raw trigger must not erase
+ * that prose. Whitespace after the label belongs to the selected trigger so
+ * the replacement can add its own single separator.
+ */
+export function composerTriggerForSelection(
+ trigger: ComposerTrigger,
+ label: string,
+): ComposerTrigger {
+ const selectedLabel = label.trim();
+ if (trigger.type !== "at" || !selectedLabel) return trigger;
+
+ const matchedPrefix = trigger.query.slice(0, selectedLabel.length);
+ if (matchedPrefix.toLowerCase() !== selectedLabel.toLowerCase()) return trigger;
+
+ const remainder = trigger.query.slice(selectedLabel.length);
+ if (remainder.length > 0 && !/^[ \t]/.test(remainder)) {
+ return trigger;
+ }
+ const separator = remainder.match(/^[ \t]*/)?.[0] ?? "";
+ return {
+ ...trigger,
+ query: `${matchedPrefix}${separator}`,
+ };
+}
+
/**
* Replace exactly the trigger span (trigger character through the end of the
* typed query) with `insertion`, leaving surrounding text untouched so
diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
index b8cba1131..dad59cc40 100644
--- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
+++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
@@ -194,6 +194,45 @@ enum WorkComposerTriggerDetector {
return nil
}
}
+
+ /// Keep prose typed after a selected @ item outside the replacement range.
+ /// The trigger detector intentionally accepts spaces for multi-word names;
+ /// this second pass uses the selected row's label to distinguish that name
+ /// from a trailing sentence.
+ static func matchForSelection(
+ _ match: WorkComposerTriggerMatch,
+ suggestion: WorkComposerSuggestion
+ ) -> WorkComposerTriggerMatch {
+ guard match.kind == .at else { return match }
+ let rawLabel = suggestion.insertText.hasPrefix("@")
+ ? String(suggestion.insertText.dropFirst())
+ : suggestion.title
+ let label = rawLabel.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !label.isEmpty else { return match }
+
+ let query = match.query as NSString
+ let labelLength = (label as NSString).length
+ guard query.length >= labelLength else { return match }
+ let prefix = query.substring(to: labelLength)
+ guard prefix.lowercased() == label.lowercased() else { return match }
+
+ let remainder = query.substring(from: labelLength) as NSString
+ guard remainder.length == 0 || remainder.character(at: 0) == 0x20 || remainder.character(at: 0) == 0x09 else {
+ return match
+ }
+ var separatorLength = 0
+ while separatorLength < remainder.length {
+ let character = remainder.character(at: separatorLength)
+ guard character == 0x20 || character == 0x09 else { break }
+ separatorLength += 1
+ }
+ let consumedQuery = query.substring(to: labelLength + separatorLength)
+ return WorkComposerTriggerMatch(
+ kind: match.kind,
+ query: consumedQuery,
+ range: NSRange(location: match.range.location, length: 1 + (consumedQuery as NSString).length)
+ )
+ }
}
// MARK: - Suggestion model
@@ -368,7 +407,8 @@ final class WorkComposerSuggestionController: ObservableObject {
func commit(_ suggestion: WorkComposerSuggestion) {
guard let match = activeMatch else { return }
- onCommit?(suggestion, match.range)
+ let commitMatch = WorkComposerTriggerDetector.matchForSelection(match, suggestion: suggestion)
+ onCommit?(suggestion, commitMatch.range)
clear()
}
diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
index 3147ff0d8..691d1e620 100644
--- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
+++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
@@ -77,6 +77,25 @@ final class WorkComposerTriggerDetectorTests: XCTestCase {
XCTAssertNil(detect("@a@b"))
}
+ func testAtSelectionRangePreservesTrailingProse() {
+ let text = "ask @src/foo.swift about this"
+ let match = try! XCTUnwrap(detect(text))
+ let suggestion = WorkComposerSuggestion(
+ id: "file:src/foo.swift",
+ kind: .at,
+ title: "foo.swift",
+ subtitle: "src",
+ insertText: "@src/foo.swift"
+ )
+
+ let narrowed = WorkComposerTriggerDetector.matchForSelection(match, suggestion: suggestion)
+ XCTAssertEqual(narrowed.query, "src/foo.swift ")
+ XCTAssertEqual(
+ narrowed.range,
+ NSRange(location: 4, length: ("@src/foo.swift " as NSString).length)
+ )
+ }
+
func testEmailDoesNotTrigger() {
// The `@` is glued to a preceding non-space char, so emails never trigger.
XCTAssertNil(detect("ping foo@bar"))
diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md
index 46056e39c..49826c2a6 100644
--- a/docs/features/chat/composer-and-ui.md
+++ b/docs/features/chat/composer-and-ui.md
@@ -32,7 +32,7 @@ subagents, computer use). The pane derives all visible state from the
| `ChatComposerShell.tsx` | Input container chrome reused by the composer. |
| `ChatAttachmentTray.tsx` | Inline file/image attachment tray inside the composer. Image attachments render an inline thumbnail, open a full-size lightbox on click, and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage` so the user can paste them into another app. Pasted images can pass a seeded preview URL from the composer while the temp file is being saved; tray-only image refs fall back to `window.ade.app.getImageDataUrl`. Non-image attachments fall back to the file glyph. |
| `ChatCommandMenu.tsx` | Popover for slash commands and the sectioned `@` menu: Files plus Chats / Lanes / Terminals entity mentions. 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; 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: the file index returns shallowest-first results and the mention action returns recency-ranked entities, so the menu is never empty before typing. 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 the selected entity title (see `shared/chatMentions.ts`). |
-| `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 `@`; `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. |
+| `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. |
| `apps/desktop/src/shared/smartLinks.ts` | Cross-client URL catalog and deterministic fallback labels. Recognizes GitHub PR/issue/repo/commit/action-run links, Linear issues, `ade://` deeplinks, and generic HTTP(S) pages; trims sentence punctuation, caps each draft at 12 matches, and keeps the canonical URL separate from optional title/favicon metadata. Desktop, hosted web, and ADE Code import this contract; iOS mirrors it in `WorkSmartLinkDetector`. |
| `apps/desktop/src/main/services/chat/smartLinkPreviewService.ts` | Runtime-owned best-effort metadata resolver. GitHub and Linear titles use configured provider services; generic pages use bounded public-network HTML/favicon reads with DNS pinning and SSRF checks. Generic previews cache at most 256 public entries for 30 minutes (five minutes for metadata misses); credential-backed provider results are never stored in that process-global cache. Any error returns the deterministic local preview rather than blocking composition. |
| `ChatTasksPanel.tsx` | Todo list rendered from `todo_update` events. |
@@ -299,12 +299,16 @@ that could not work without it.
block — see [features/linear-integration/README.md](../linear-integration/README.md).
- **Typed triggers anywhere.** `detectComposerTrigger(text, cursorPos)`
(`shared/composerTriggers.ts`) finds an in-progress `/command` or
- `@file` token that ends at the cursor — at any position in the draft,
+ `@file` or multi-word `@chat` token that ends at the cursor — at any position
+ in the draft,
not just position 0 (`fix @src/foo.ts then run /test`). Both the rich
contenteditable and the plain textarea consume it (as do the
`WorkViewArea` continue-composer and the ade-code TUI, which import
- the same module). Selecting a suggestion replaces exactly the trigger
- span (`replaceComposerTriggerSpan`); a lone leading command keeps the
+ the same module). A matching multi-word suggestion remains available while
+ following prose is typed, and selecting it replaces only the `@` plus the
+ selected label (`composerTriggerForSelection`), preserving that prose.
+ Selecting a suggestion otherwise replaces exactly the trigger span
+ (`replaceComposerTriggerSpan`); a lone leading command keeps the
legacy fill-the-draft path so the local `/clear` intercept and
argument-hint scaffold still work. Confirmed tokens render as chips:
the rich editor inserts non-editable chip nodes
diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md
index ffc3d92f4..8b3f9cd76 100644
--- a/docs/features/lanes/README.md
+++ b/docs/features/lanes/README.md
@@ -78,7 +78,7 @@ Renderer components:
| `renderer/components/app/App.tsx` | Project tab host and route keep-alive shell. Keeps the Work surface mounted after first visit and now does the same for `/lanes`, parking the inactive Lanes surface with `inert` / `aria-hidden` instead of unmounting it. Parked route and project surfaces also set `data-ade-animation-state="paused"`, which lets the global renderer stylesheet stop hidden CSS animations until the surface is active again. Surfaces are keyed by runtime binding (`local:` or the remote binding key) so local and remote views of the same root do not share lane/work state. During cold project switches it renders a transition veil over the old project surface until the target project hydrates. |
| `renderer/components/app/toast/{toastStore.ts,ToastStack.tsx,useLaneEventToasts.ts}` | Shared renderer toast primitive mounted from `AppShell`. `useLaneEventToasts` subscribes to `lanes.onLifecycleEvent` and `lanes.rebaseSubscribe`, turning lane-created/archive/delete and final automated rebase outcomes into compact global notices; created-lane toasts include a `View` action that routes to `/lanes?laneId=...&focus=single`. |
| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Create-lane state lives in `CreateLaneDialogHost`; `LanesPage` owns only open/prefill routing, blocks forced close while the host is busy, and focuses the new lane after the host refreshes the lane list while the dialog stays open for setup progress. The lane filter, pinned lane ids, and expanded lane id live in the active project's `WorkProjectViewState`, not component-local state, so route/tab/project remounts restore the correct project's view. Each lane row renders the lane's retained live PR set: the current-branch PR is `active`, older PRs from previous branches are `previous`, and two or more rows collapse to the newest open/draft PR (or newest terminal activity) plus a `+N` counter. Hover/focus reveals every PR with its own state, CI, review, and previous-branch signals; the fixed, viewport-clamped hover card is rendered outside row overflow, and the counter opens the lane-filtered PR workspace. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Those forced refreshes are marked `automaticRefresh` — nobody pressed anything — so they stay inside the service's GitHub failure ladder and a rate-limited GitHub is not re-asked on every branch-signature change (see [pull-requests](../pull-requests/README.md#github-read-failure-ladder)); `refreshLaneGithubPrTags` returns `false` when it fell back to the last usable snapshot, which is what the retry logic keys on. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents; within each dependency-safe batch the page dispatches up to two lane deletes at a time and records per-lane failures, and a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. |
-| `renderer/components/lanes/LanePrBadgePopover.tsx`, `LanePrHoverCard.tsx` | Multi-PR lane badge and its shared fixed-position portal. The primary chip chooses the newest open/draft PR before falling back to recent terminal activity; the complete list is clamped to the viewport and dismissed on scroll/resize so row/card overflow cannot hide it. |
+| `renderer/components/lanes/LanePrBadgePopover.tsx`, `LanePrHoverCard.tsx` | Multi-PR lane badge and its shared fixed-position portal. The primary chip chooses the newest open/draft PR before falling back to recent terminal activity; the complete list is clamped to the viewport and dismissed on viewport scroll/resize so row/card overflow cannot hide it, while its own list remains scrollable. |
| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR role derivation (`active` vs `previous`), multi-PR ordering, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchWithConcurrency` for limited parallel teardown inside each dependency-safe batch. |
| `renderer/hooks/useLaneListInvalidation.ts` | Shared lane-list invalidation hook used by Lanes, Graph, and PRs. It subscribes to `window.ade.lanes.onLifecycleEvent`, clears renderer read coalescing immediately, debounces a decorated `refreshLanes` call, runs one delayed follow-up refresh to cover daemon/write-to-read races, and self-heals stale visible lists on focus/visibility without polling. Hidden lifecycle events are replayed when the surface becomes visible. |
| `renderer/lib/laneReadCache.ts` | Renderer-side in-flight coalescing for lane list/snapshot/keybinding reads. Lane lifecycle invalidation clears lane list/snapshot requests and bumps a generation token so a newly requested read never reuses an older in-flight lane snapshot. |
diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md
index 599dd2664..83aa97720 100644
--- a/docs/features/terminals-and-sessions/README.md
+++ b/docs/features/terminals-and-sessions/README.md
@@ -1275,7 +1275,9 @@ Renderer surfaces:
singleton session row's lane submenu, and the pointer-safe/keyboard-accessible
submenu primitive. Multi-PR badges choose the newest open/draft PR (or newest
terminal activity) for the collapsed chip and render their detail list in a
- viewport-clamped portal, so session-card overflow cannot hide it. The badge
+ viewport-clamped portal, so session-card overflow cannot hide it; the list
+ remains scrollable and keyboard reachable (ArrowDown opens it, Escape closes it and restores
+ focus). The badge
is presentation-only; where it opens is decided
once by `openLanePr`, which sends a PR on the machine you are bound to into
the PRs tab and a foreign one to GitHub, since a PR id resolves only on the
diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md
index df3cf55b9..bbd87dba9 100644
--- a/docs/features/terminals-and-sessions/ui-surfaces.md
+++ b/docs/features/terminals-and-sessions/ui-surfaces.md
@@ -281,7 +281,9 @@ The full card is one full-bleed row with three lines:
id on the machine the project tab is bound to. When a lane has multiple PRs,
the badge represents the newest open/draft PR, or the newest terminal
activity when no PR is active, and its `+N` list is rendered in a
- viewport-clamped portal so the session card cannot clip the details. While the owning lane is
+ viewport-clamped portal so the session card cannot clip the details. The portal remains
+ keyboard reachable (ArrowDown opens it, Escape closes it and restores focus) and its own
+ list can scroll without dismissing. While the owning lane is
mid background AI naming, every visible lane-label position (singleton row,
hover detail, or grouped header) uses the shared animated `Naming lane…`
placeholder; the persisted deterministic fallback stays hidden unless
From 431fa9921e7e1d5ae3aec64a75aeed7ee9ea0edc Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Mon, 10 Aug 2026 04:21:28 -0400
Subject: [PATCH 03/17] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20addre?=
=?UTF-8?q?ss=20current-head=20review=20findings?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/chat/AgentChatComposer.test.tsx | 3 ++-
.../components/chat/AgentChatComposer.tsx | 16 +++++++++++++++-
apps/desktop/src/shared/chatMentions.test.ts | 10 ++++++++++
apps/desktop/src/shared/chatMentions.ts | 10 ++++++----
4 files changed, 33 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
index 94bb6d8c1..562cad3b8 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
@@ -711,7 +711,8 @@ describe("AgentChatComposer", () => {
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("[aria-hidden]")?.textContent).not.toContain("@chat:chat-1");
+ 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("does not consume prose after a matching spaced chat mention", async () => {
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
index 47140ad11..e4a6f7a50 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
@@ -2015,12 +2015,26 @@ export function AgentChatComposer({
const displayText = token.kind === "mention"
? mentionLabelsRef.current.get(tokenText)?.trim() || tokenText
: tokenText;
+ const isLabeledMention = token.kind === "mention" && displayText !== tokenText;
segments.push(
- {displayText}
+ {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;
diff --git a/apps/desktop/src/shared/chatMentions.test.ts b/apps/desktop/src/shared/chatMentions.test.ts
index 185ead212..7400ea69e 100644
--- a/apps/desktop/src/shared/chatMentions.test.ts
+++ b/apps/desktop/src/shared/chatMentions.test.ts
@@ -270,6 +270,16 @@ describe("chat mention ranking", () => {
expect(ranked.map((r) => r.id)).toEqual(["short"]);
});
+ it("does not keep a subtitle-only prefix when prose follows it", () => {
+ const ranked = rankChatMentionSuggestions(
+ [{ id: "subtitle", title: "Unrelated", subtitle: "Primary · codex", lastActivityAt: 10 }],
+ "Primary · codex please review",
+ 10,
+ );
+
+ expect(ranked).toEqual([]);
+ });
+
it("honors the per-kind cap and is stable for equal rows", () => {
const ties = [
{ id: "z", title: "same", lastActivityAt: 5 },
diff --git a/apps/desktop/src/shared/chatMentions.ts b/apps/desktop/src/shared/chatMentions.ts
index 200faa017..77eb7df79 100644
--- a/apps/desktop/src/shared/chatMentions.ts
+++ b/apps/desktop/src/shared/chatMentions.ts
@@ -247,14 +247,16 @@ export function carryChatMentionBlocks(source: string, target: string): string {
function scoreChatMentionMatch(
haystack: string,
loweredQuery: string,
+ allowTrailingProse = false,
): number | null {
if (!loweredQuery.length) return 0;
const target = haystack.toLowerCase();
if (target === loweredQuery) return 0;
// Once a title is an exact prefix, keep it visible while the user continues
- // ordinary prose after the mention. Exact longer titles still win above this
- // fallback, so a real multi-word title is selected before a shorter prefix.
- if (loweredQuery.startsWith(`${target} `)) return 1;
+ // ordinary prose after the mention. This is intentionally title-only: a
+ // subtitle prefix is not a confirmed label, so it must not widen the
+ // replacement span and consume the prose that follows it.
+ if (allowTrailingProse && loweredQuery.startsWith(`${target} `)) return 1;
if (target.startsWith(loweredQuery)) return 1;
if (target.includes(loweredQuery)) return 2;
// Subsequence fallback: every query char appears in order.
@@ -282,7 +284,7 @@ export function rankChatMentionSuggestions<
scored.push({ item, score: 0 });
continue;
}
- const titleScore = scoreChatMentionMatch(item.title, trimmed);
+ const titleScore = scoreChatMentionMatch(item.title, trimmed, true);
const subtitleScore = item.subtitle
? scoreChatMentionMatch(item.subtitle, trimmed)
: null;
From c6ba626d20718b084e595e5e063e701544dfe2ab Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Mon, 10 Aug 2026 04:42:33 -0400
Subject: [PATCH 04/17] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20prese?=
=?UTF-8?q?rve=20file=20matches=20after=20prose?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/ade-cli/src/tuiClient/app.tsx | 10 ++++++----
.../chat/AgentChatComposer.test.tsx | 19 ++++++++++++++++++
.../components/chat/ChatCommandMenu.tsx | 5 +++--
.../src/shared/composerTriggers.test.ts | 7 +++++++
apps/desktop/src/shared/composerTriggers.ts | 15 ++++++++++++++
.../Work/WorkComposerTypedTriggers.swift | 20 +++++++++++++++++--
.../WorkComposerTriggerDetectorTests.swift | 15 ++++++++++++++
7 files changed, 83 insertions(+), 8 deletions(-)
diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx
index 3775a299e..8ac42b809 100644
--- a/apps/ade-cli/src/tuiClient/app.tsx
+++ b/apps/ade-cli/src/tuiClient/app.tsx
@@ -15,6 +15,7 @@ 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,
@@ -7341,6 +7342,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
}
let cancelled = false;
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();
@@ -7391,16 +7393,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>("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(() => []);
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
index 562cad3b8..6be988401 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
@@ -682,6 +682,25 @@ 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 spaced chat mentions searchable and displays the chat title in the chip", async () => {
const onSearchMentions = vi.fn().mockResolvedValue([{
kind: "chat" as const,
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/shared/composerTriggers.test.ts b/apps/desktop/src/shared/composerTriggers.test.ts
index 9a7a27ff0..9891fcb2f 100644
--- a/apps/desktop/src/shared/composerTriggers.test.ts
+++ b/apps/desktop/src/shared/composerTriggers.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ composerFileSearchQuery,
composerTriggerForSelection,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
@@ -60,6 +61,12 @@ describe("detectComposerTrigger", () => {
expect(detectComposerTrigger("@a ", 3)).toEqual({ type: "at", query: "a ", start: 0 });
});
+ it("narrows path-like file queries before trailing prose", () => {
+ expect(composerFileSearchQuery("src/foo.ts about this")).toBe("src/foo.ts");
+ expect(composerFileSearchQuery("src/my file.ts about this")).toBe("src/my file.ts");
+ expect(composerFileSearchQuery("a b c")).toBe("a b c");
+ });
+
it("does not let an at query cross a newline or another at sign", () => {
expect(detectComposerTrigger("@a\nb", 4)).toBeNull();
expect(detectComposerTrigger("@a@b", 4)).toBeNull();
diff --git a/apps/desktop/src/shared/composerTriggers.ts b/apps/desktop/src/shared/composerTriggers.ts
index 5746dfd68..a0f6da775 100644
--- a/apps/desktop/src/shared/composerTriggers.ts
+++ b/apps/desktop/src/shared/composerTriggers.ts
@@ -21,6 +21,10 @@ export type ComposerTrigger = {
// another `@` or a newline, so emails and cross-line prose never trigger.
const AT_TRIGGER_RE = /(?:^|[ \t\r\n])(@([^@\r\n]*))$/;
const SLASH_TRIGGER_RE = /(?:^|\s)(\/([^\s/]*))$/;
+// A path-like leading label can be separated from prose without making the
+// same assumption for ordinary multiword chat titles. This covers the common
+// `src/foo.ts about this` form, including paths whose filename contains spaces.
+const FILE_QUERY_RE = /^((?:.+?\.[A-Za-z0-9_-]+)|(?:\S+[\\/]\S+))(?:[ \t]+.*)?$/;
export function detectComposerTrigger(text: string, cursorPos: number): ComposerTrigger | null {
const cursor = Math.max(0, Math.min(Math.floor(cursorPos), text.length));
@@ -37,6 +41,17 @@ export function detectComposerTrigger(text: string, cursorPos: number): Composer
return { type: "slash", query: slash![2] ?? "", start: slashStart };
}
+/**
+ * Remove trailing prose from an @ file query while leaving chat-name queries
+ * untouched. File selection still uses the raw trigger below, so the shared
+ * label-narrowing helper preserves the prose in the replacement range.
+ */
+export function composerFileSearchQuery(query: string): string {
+ const trimmed = query.trim();
+ if (!trimmed) return "";
+ return FILE_QUERY_RE.exec(trimmed)?.[1] ?? trimmed;
+}
+
/**
* Narrow an @ trigger to the selected item's leading label when the user has
* continued typing prose after it. The menu can keep a prefix suggestion
diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
index dad59cc40..8d982d752 100644
--- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
+++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
@@ -162,6 +162,9 @@ struct WorkComposerTriggerMatch: Equatable {
enum WorkComposerTriggerDetector {
private static let slashRegex = try! NSRegularExpression(pattern: "(?:^|\\s)/([^\\s/]*)$")
private static let atRegex = try! NSRegularExpression(pattern: "(?:^|[ \\t\\r\\n])@([^@\\r\\n]*)$")
+ private static let fileQueryRegex = try! NSRegularExpression(
+ pattern: "^((?:.+?\\.[A-Za-z0-9_-]+)|(?:\\S+[\\\\/]\\S+))(?:[ \\t]+.*)?$"
+ )
static func detect(in text: NSString, cursor: Int) -> WorkComposerTriggerMatch? {
guard cursor >= 0, cursor <= text.length else { return nil }
@@ -195,6 +198,19 @@ enum WorkComposerTriggerDetector {
}
}
+ /// Keep path-like file labels searchable when the user continues ordinary
+ /// prose, while leaving multiword chat-name queries untouched.
+ static func fileSearchQuery(for query: String) -> String {
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "" }
+ let nsQuery = trimmed as NSString
+ let range = NSRange(location: 0, length: nsQuery.length)
+ guard let match = fileQueryRegex.firstMatch(in: trimmed, range: range) else { return trimmed }
+ let labelRange = match.range(at: 1)
+ guard labelRange.location != NSNotFound else { return trimmed }
+ return nsQuery.substring(with: labelRange)
+ }
+
/// Keep prose typed after a selected @ item outside the replacement range.
/// The trigger detector intentionally accepts spaces for multi-word names;
/// this second pass uses the selected row's label to distinguish that name
@@ -333,7 +349,7 @@ final class WorkComposerSuggestionController: ObservableObject {
if let match = activeMatch, match.kind == .at {
// An @ trigger typed against the previous lane re-fetches against
// the new one instead of keeping the superseded results.
- scheduleFileFetch(query: match.query)
+ scheduleFileFetch(query: WorkComposerTriggerDetector.fileSearchQuery(for: match.query))
} else if isLoading {
isLoading = false
}
@@ -401,7 +417,7 @@ final class WorkComposerSuggestionController: ObservableObject {
isLoading = false
suggestions = WorkComposerSlashCatalog.suggestions(provider: provider, query: match.query)
case .at:
- scheduleFileFetch(query: match.query)
+ scheduleFileFetch(query: WorkComposerTriggerDetector.fileSearchQuery(for: match.query))
}
}
diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
index 691d1e620..b58363cd6 100644
--- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
+++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
@@ -96,6 +96,21 @@ final class WorkComposerTriggerDetectorTests: XCTestCase {
)
}
+ func testFileSearchQueryPreservesPathBeforeTrailingProse() {
+ XCTAssertEqual(
+ WorkComposerTriggerDetector.fileSearchQuery(for: "src/foo.ts about this"),
+ "src/foo.ts"
+ )
+ XCTAssertEqual(
+ WorkComposerTriggerDetector.fileSearchQuery(for: "src/my file.ts about this"),
+ "src/my file.ts"
+ )
+ XCTAssertEqual(
+ WorkComposerTriggerDetector.fileSearchQuery(for: "a b c"),
+ "a b c"
+ )
+ }
+
func testEmailDoesNotTrigger() {
// The `@` is glued to a preceding non-space char, so emails never trigger.
XCTAssertNil(detect("ping foo@bar"))
From e1567fcd4cd0e47e1c76fe36099b55132611275a Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Mon, 10 Aug 2026 05:11:16 -0400
Subject: [PATCH 05/17] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20prese?=
=?UTF-8?q?rve=20mention=20labels=20and=20path=20matches?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/ade-cli/src/tuiClient/app.tsx | 9 ++-
.../services/files/fileSearchIndexService.ts | 33 +++++++--
.../main/services/files/fileService.test.ts | 23 ++++++
.../chat/AgentChatComposer.test.tsx | 58 +++++++++++++++
.../components/chat/AgentChatComposer.tsx | 70 +++++++++++++++++--
.../components/chat/AgentChatPane.tsx | 32 +++++++++
.../src/renderer/lib/lanePrBadge.test.ts | 12 +++-
apps/desktop/src/renderer/lib/lanePrBadge.ts | 9 +--
.../src/shared/composerTriggers.test.ts | 1 +
apps/desktop/src/shared/composerTriggers.ts | 15 ++--
.../Work/WorkComposerTypedTriggers.swift | 2 +-
.../WorkComposerTriggerDetectorTests.swift | 4 ++
12 files changed, 241 insertions(+), 27 deletions(-)
diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx
index 8ac42b809..5f49c525b 100644
--- a/apps/ade-cli/src/tuiClient/app.tsx
+++ b/apps/ade-cli/src/tuiClient/app.tsx
@@ -2514,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} `);
+}
+
type MentionRemoteCacheEntry = {
filesByQuery: Map>;
commits: Array> | null;
@@ -7347,8 +7351,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
if (!query) return true;
const label = suggestion.label.toLowerCase();
return (
- label.includes(query)
- || query.startsWith(`${label} `)
+ matchesMentionTarget(label, query)
|| suggestion.insertText.toLowerCase().includes(query)
|| Boolean(suggestion.detail?.toLowerCase().includes(query))
);
@@ -7456,7 +7459,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
const title = String(pr.title ?? "");
const number = String(pr.number ?? pr.prNumber ?? "");
const loweredTitle = title.toLowerCase();
- return !query || loweredTitle.includes(query) || query.startsWith(`${loweredTitle} `) || number.includes(query);
+ return !query || matchesMentionTarget(loweredTitle, query) || number.includes(query);
})
.slice(0, 5)
.map((pr) => {
diff --git a/apps/desktop/src/main/services/files/fileSearchIndexService.ts b/apps/desktop/src/main/services/files/fileSearchIndexService.ts
index 5591d10a1..bbac391ea 100644
--- a/apps/desktop/src/main/services/files/fileSearchIndexService.ts
+++ b/apps/desktop/src/main/services/files/fileSearchIndexService.ts
@@ -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;
+ 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);
+ }
+ return best;
}
async function cooperativeYield(): Promise {
diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts
index 18c655830..8da0b3d22 100644
--- a/apps/desktop/src/main/services/files/fileService.test.ts
+++ b/apps/desktop/src/main/services/files/fileService.test.ts
@@ -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");
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
index 6be988401..1d7a94d95 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
@@ -701,6 +701,25 @@ describe("AgentChatComposer", () => {
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,
@@ -734,6 +753,45 @@ describe("AgentChatComposer", () => {
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("does not consume prose after a matching spaced chat mention", async () => {
const onSearchMentions = vi.fn().mockResolvedValue([{
kind: "chat" as const,
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
index e4a6f7a50..ad9f7f88d 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
@@ -51,6 +51,7 @@ import {
import {
formatChatMentionToken,
isChatMentionTokenBody,
+ parseChatMentions,
} from "../../../shared/chatMentions";
import type { ChatMentionSuggestion } from "../../../shared/types/chatMentions";
import { cn } from "../ui/cn";
@@ -1509,6 +1510,8 @@ export function AgentChatComposer({
onReasoningEffortChange,
onFastModeChange,
onDraftChange,
+ mentionLabels,
+ onMentionLabelChange,
onClearDraft,
onSubmit,
onSubmitBlocked,
@@ -1663,6 +1666,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;
@@ -1859,7 +1865,10 @@ export function AgentChatComposer({
// 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