(
+ "button, a[href], input, select, textarea, [role=\"button\"], [tabindex]:not([tabindex=\"-1\"])",
+ );
+ firstInteractive?.focus();
+ }, [anchor, panelRef]);
+
+ useEffect(() => () => {
+ if (closeTimerRef.current != null) clearTimeout(closeTimerRef.current);
+ }, []);
+
+ const trigger = (
+ {
+ event.stopPropagation();
+ }}
+ onMouseDown={(event) => {
+ event.stopPropagation();
+ }}
+ 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();
+ }}
+ >
+ {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 = (
+ event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ 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();
+ }}
+ 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..78400f6e5 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, waitFor } 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,9 +106,69 @@ describe("LanePrBadge", () => {
/>,
);
+ openLanePrHoverCard();
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("does not bubble portaled panel clicks into the enclosing row", () => {
+ const onRowClick = vi.fn();
+ const onRowMouseDown = vi.fn();
+ render(
+
+
+
,
+ );
+
+ const hoverCard = openLanePrHoverCard();
+ fireEvent.mouseDown(hoverCard);
+ fireEvent.click(hoverCard);
+
+ expect(onRowMouseDown).not.toHaveBeenCalled();
+ expect(onRowClick).not.toHaveBeenCalled();
+ });
+
+ 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(
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..42b04dde1 100644
--- a/apps/desktop/src/renderer/lib/lanePrBadge.test.ts
+++ b/apps/desktop/src/renderer/lib/lanePrBadge.test.ts
@@ -9,17 +9,17 @@ import {
selectPrimaryLanePr,
} from "./lanePrBadge";
-type TestPr = { id: string; state: PrState; updatedAt: string; githubPrNumber: number };
+type TestPr = { id: string; state: PrState; updatedAt?: string | null; githubPrNumber: number };
-function pr(id: string, state: PrState, updatedAt: string, githubPrNumber: number): TestPr {
+function pr(id: string, state: PrState, updatedAt: string | null | undefined, githubPrNumber: number): TestPr {
return { id, state, updatedAt, githubPrNumber };
}
describe("primaryPrStateRank", () => {
- it("ranks open < draft < merged < closed", () => {
+ it("ranks open < draft < terminal history", () => {
expect(primaryPrStateRank("open")).toBeLessThan(primaryPrStateRank("draft"));
expect(primaryPrStateRank("draft")).toBeLessThan(primaryPrStateRank("merged"));
- expect(primaryPrStateRank("merged")).toBeLessThan(primaryPrStateRank("closed"));
+ expect(primaryPrStateRank("merged")).toBe(primaryPrStateRank("closed"));
});
});
@@ -68,6 +68,22 @@ describe("pickPrimaryPr", () => {
],
expected: "merged",
},
+ {
+ name: "newer closed activity beats older merged history",
+ prs: [
+ pr("old-merged", "merged", "2026-07-01T00:00:00Z", 20),
+ pr("new-closed", "closed", "2026-07-04T00:00:00Z", 1),
+ ],
+ expected: "new-closed",
+ },
+ {
+ 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) {
@@ -100,7 +116,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 +126,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 +137,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..35d1197a7 100644
--- a/apps/desktop/src/renderer/lib/lanePrBadge.ts
+++ b/apps/desktop/src/renderer/lib/lanePrBadge.ts
@@ -5,8 +5,9 @@ import { COLORS } from "../components/lanes/laneDesignTokens";
/**
* Rank used to choose the one PR that represents a lane in a dense list:
- * an open PR is the most actionable, then a draft, then a merged/closed one.
- * Lower rank wins.
+ * an open PR is the most actionable, then a draft, then terminal history.
+ * Merged and closed PRs share the terminal rank so their activity timestamps
+ * decide which history is most useful. Lower rank wins.
*/
export function primaryPrStateRank(state: PrState): number {
switch (state) {
@@ -17,18 +18,25 @@ export function primaryPrStateRank(state: PrState): number {
case "merged":
return 2;
default:
- return 3; // closed
+ return 2; // closed
}
}
-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);
- if (Number.isFinite(aUpdated) && Number.isFinite(bUpdated) && aUpdated !== bUpdated) {
+ const aUpdated = Date.parse(a.updatedAt ?? "");
+ const bUpdated = Date.parse(b.updatedAt ?? "");
+ 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;
@@ -36,11 +44,11 @@ function comparePrimaryPr(a: PrimaryPrComparable, b: PrimaryPrComparable): numbe
/**
* Pick the single PR that best represents a set of PRs: prefer open over draft
- * over merged/closed; among equals the most recently updated (then highest
+ * over terminal history; among equals the most recently updated (then highest
* 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 +57,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 +73,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/chatMentions.test.ts b/apps/desktop/src/shared/chatMentions.test.ts
index 6798d14ba..d90dd0765 100644
--- a/apps/desktop/src/shared/chatMentions.test.ts
+++ b/apps/desktop/src/shared/chatMentions.test.ts
@@ -257,6 +257,42 @@ 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("prefers the longest title prefix before recency", () => {
+ const ranked = rankChatMentionSuggestions(
+ [
+ { id: "short", title: "Foo", lastActivityAt: 900 },
+ { id: "long", title: "Foo Bar", lastActivityAt: 1 },
+ ],
+ "Foo Bar please",
+ 10,
+ );
+
+ expect(ranked.map((r) => r.id)).toEqual(["long", "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 a4d01652d..79c0cc67f 100644
--- a/apps/desktop/src/shared/chatMentions.ts
+++ b/apps/desktop/src/shared/chatMentions.ts
@@ -244,15 +244,29 @@ export function carryChatMentionBlocks(source: string, target: string): string {
* `null` means "no match, drop the row". Mirrors the tiering the ⌘K palette
* uses: exact > prefix > substring > subsequence.
*/
+type ChatMentionMatch = {
+ score: number;
+ /** Length of a confirmed title prefix, used to prefer the longest label. */
+ titlePrefixLength: number;
+};
+
function scoreChatMentionMatch(
haystack: string,
loweredQuery: string,
-): number | null {
- if (!loweredQuery.length) return 0;
+ allowTrailingProse = false,
+): ChatMentionMatch | null {
+ if (!loweredQuery.length) return { score: 0, titlePrefixLength: 0 };
const target = haystack.toLowerCase();
- if (target === loweredQuery) return 0;
- if (target.startsWith(loweredQuery)) return 1;
- if (target.includes(loweredQuery)) return 2;
+ if (target === loweredQuery) return { score: 0, titlePrefixLength: 0 };
+ // Once a title is an exact prefix, keep it visible while the user continues
+ // 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 { score: 1, titlePrefixLength: target.length };
+ }
+ if (target.startsWith(loweredQuery)) return { score: 1, titlePrefixLength: 0 };
+ if (target.includes(loweredQuery)) return { score: 2, titlePrefixLength: 0 };
// Subsequence fallback: every query char appears in order.
let cursor = 0;
for (const char of loweredQuery) {
@@ -260,7 +274,7 @@ function scoreChatMentionMatch(
if (found < 0) return null;
cursor = found + 1;
}
- return 3;
+ return { score: 3, titlePrefixLength: 0 };
}
/**
@@ -272,23 +286,28 @@ export function rankChatMentionSuggestions<
T extends { id: string; title: string; subtitle?: string; lastActivityAt?: number | null },
>(candidates: T[], query: string, limit: number): T[] {
const trimmed = query.trim().toLowerCase();
- const scored: Array<{ item: T; score: number }> = [];
+ const scored: Array<{ item: T; score: number; titlePrefixLength: number }> = [];
for (const item of candidates) {
if (!trimmed.length) {
- scored.push({ item, score: 0 });
+ scored.push({ item, score: 0, titlePrefixLength: 0 });
continue;
}
- const titleScore = scoreChatMentionMatch(item.title, trimmed);
+ const titleScore = scoreChatMentionMatch(item.title, trimmed, true);
const subtitleScore = item.subtitle
? scoreChatMentionMatch(item.subtitle, trimmed)
: null;
// A subtitle hit is always weaker than any title hit.
- const score = titleScore ?? (subtitleScore === null ? null : subtitleScore + 4);
- if (score === null) continue;
- scored.push({ item, score });
+ const match = titleScore ?? (subtitleScore === null
+ ? null
+ : { score: subtitleScore.score + 4, titlePrefixLength: 0 });
+ if (match === null) continue;
+ scored.push({ item, score: match.score, titlePrefixLength: match.titlePrefixLength });
}
scored.sort((a, b) => {
if (a.score !== b.score) return a.score - b.score;
+ if (a.titlePrefixLength !== b.titlePrefixLength) {
+ return b.titlePrefixLength - a.titlePrefixLength;
+ }
const aAt = a.item.lastActivityAt ?? 0;
const bAt = b.item.lastActivityAt ?? 0;
if (aAt !== bAt) return bAt - aAt;
diff --git a/apps/desktop/src/shared/composerTriggers.test.ts b/apps/desktop/src/shared/composerTriggers.test.ts
index 9389bf051..550554214 100644
--- a/apps/desktop/src/shared/composerTriggers.test.ts
+++ b/apps/desktop/src/shared/composerTriggers.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
+ composerFileSearchQuery,
+ composerTriggerForSelection,
+ composerTriggerHasConfirmedPrefix,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
findConfirmedComposerTokens,
@@ -51,6 +54,26 @@ 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("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("src/my folder about this")).toBe("src/my folder about this");
+ 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();
+ });
+
it("does not trigger on emails", () => {
const text = "mail user@doma";
expect(detectComposerTrigger(text, text.length)).toBeNull();
@@ -78,6 +101,118 @@ 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("narrows shorthand file labels without consuming trailing prose", () => {
+ const text = "ask @foo.ts about this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const selected = composerTriggerForSelection(trigger, "src/foo.ts", "file");
+
+ expect(selected.query).toBe("foo.ts ");
+ expect(replaceComposerTriggerSpan(text, selected, "@src/foo.ts ").text).toBe("ask @src/foo.ts about this");
+ });
+
+ it("narrows a spaced extensionless basename without consuming trailing prose", () => {
+ const text = "ask @my folder about this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const selected = composerTriggerForSelection(trigger, "src/my folder", "file");
+
+ expect(selected.query).toBe("my folder ");
+ expect(replaceComposerTriggerSpan(text, selected, "@src/my folder ").text).toBe("ask @src/my folder about this");
+ });
+
+ it("preserves prose after an extensionless path-prefix match", () => {
+ const text = "ask @src/my review this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const selected = composerTriggerForSelection(trigger, "src/my folder", "file");
+
+ expect(selected.query).toBe("src/my ");
+ expect(replaceComposerTriggerSpan(text, selected, "@src/my folder ").text).toBe(
+ "ask @src/my folder review this",
+ );
+ });
+
+ it("narrows a root-level spaced file prefix without consuming trailing prose", () => {
+ const text = "ask @my review this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const selected = composerTriggerForSelection(trigger, "my file", "file");
+
+ expect(selected.query).toBe("my ");
+ expect(replaceComposerTriggerSpan(text, selected, "@my file ").text).toBe(
+ "ask @my file review this",
+ );
+ expect(composerTriggerForSelection(trigger, "my file", "mention")).toEqual(trigger);
+ });
+
+ it("preserves prose after an intermediate path-component match", () => {
+ const text = "ask @my review this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const selected = composerTriggerForSelection(trigger, "src/my/file", "file");
+
+ expect(selected.query).toBe("my ");
+ expect(replaceComposerTriggerSpan(text, selected, "@src/my/file ").text).toBe(
+ "ask @src/my/file review this",
+ );
+ });
+
+ it("preserves prose after a nested basename prefix match", () => {
+ const text = "ask @my review this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const selected = composerTriggerForSelection(trigger, "docs/my file", "file");
+
+ expect(selected.query).toBe("my ");
+ expect(replaceComposerTriggerSpan(text, selected, "@docs/my file ").text).toBe(
+ "ask @docs/my file review this",
+ );
+ });
+
+ it("preserves prose after a substring path-component match", () => {
+ const text = "ask @ead review this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const selected = composerTriggerForSelection(trigger, "docs/README", "file");
+
+ expect(selected.query).toBe("ead ");
+ expect(replaceComposerTriggerSpan(text, selected, "@docs/README ").text).toBe(
+ "ask @docs/README review this",
+ );
+ });
+
+ it("recognizes a confirmed @ token as a terminated trigger", () => {
+ const text = "ask @src/foo.ts about this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const confirm = { isFile: (body: string) => body === "src/foo.ts" };
+
+ expect(composerTriggerHasConfirmedPrefix(text, trigger, confirm)).toBe(true);
+ expect(composerTriggerHasConfirmedPrefix("ask @src/foo.ts", detectComposerTrigger("ask @src/foo.ts", 15)!, confirm)).toBe(false);
+ });
+
+ it("recognizes a confirmed file token with spaces as terminated", () => {
+ const text = "ask @src/my folder about this";
+ const trigger = detectComposerTrigger(text, text.length)!;
+ const confirm = { isFile: (body: string) => body === "src/my folder" };
+
+ expect(composerTriggerHasConfirmedPrefix(text, trigger, confirm)).toBe(true);
+ expect(findConfirmedComposerTokens(text, {
+ ...confirm,
+ isCommand: () => false,
+ })).toEqual([{ start: 4, end: 18, kind: "file" }]);
+ });
+
it("replaces exactly the trigger span mid-sentence", () => {
const text = "fix @src/f then run /te tomorrow";
const trigger = { start: 20, query: "te" };
@@ -126,6 +261,15 @@ describe("findConfirmedComposerTokens", () => {
expect(findConfirmedComposerTokens("path/@src/foo.ts", confirm)).toEqual([]);
expect(findConfirmedComposerTokens("a/test", confirm)).toEqual([]);
});
+
+ it("finds confirmed files whose names contain an at sign", () => {
+ const token = "@assets/icon@2x.png";
+ const text = `fix ${token} then continue`;
+ expect(findConfirmedComposerTokens(text, {
+ isFile: (body: string) => body === "assets/icon@2x.png",
+ isCommand: () => false,
+ })).toEqual([{ start: 4, end: 4 + token.length, kind: "file" }]);
+ });
});
describe("composerTriggerSpansWholeDraft", () => {
diff --git a/apps/desktop/src/shared/composerTriggers.ts b/apps/desktop/src/shared/composerTriggers.ts
index 4d7398815..010bf1ef8 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";
@@ -13,13 +13,21 @@ export type ComposerTrigger = {
start: number;
};
+export type ComposerSelectionKind = "file" | "mention";
+
// 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/]*))$/;
+// A path with a recognizable extension can be separated from prose without
+// making the same assumption for ordinary multiword chat titles. Extensionless
+// paths are kept intact; the file index resolves a leading path prefix when
+// prose follows it, which also preserves spaces in directory/file names.
+const FILE_QUERY_RE = /^(.+?\.[A-Za-z0-9_-]+)(?:[ \t]+.*)?$/;
export function detectComposerTrigger(text: string, cursorPos: number): ComposerTrigger | null {
const cursor = Math.max(0, Math.min(Math.floor(cursorPos), text.length));
@@ -36,6 +44,97 @@ export function detectComposerTrigger(text: string, cursorPos: number): Composer
return { type: "slash", query: slash![2] ?? "", start: slashStart };
}
+/**
+ * Remove trailing prose from an @ file query when a filename extension gives
+ * us an unambiguous boundary. Leave extensionless paths and chat-name queries
+ * intact; file quick-open handles a path prefix followed by prose.
+ */
+export function composerFileSearchQuery(query: string): string {
+ const trimmed = query.trim();
+ if (!trimmed) return "";
+ return FILE_QUERY_RE.exec(trimmed)?.[1] ?? trimmed;
+}
+
+function composerPathBasename(pathValue: string): string {
+ const separator = Math.max(pathValue.lastIndexOf("/"), pathValue.lastIndexOf("\\"));
+ return separator >= 0 ? pathValue.slice(separator + 1) : pathValue;
+}
+
+function composerPathPrefixForSelection(
+ query: string,
+ selectedLabel: string,
+ allowRootLevelFile: boolean,
+): string {
+ if (!allowRootLevelFile) return "";
+ const pathComponents = selectedLabel.split(/[\\/]/).filter(Boolean);
+ const words = query.trim().split(/[ \t]+/).filter(Boolean);
+ for (let wordCount = words.length - 1; wordCount > 0; wordCount -= 1) {
+ const prefix = words.slice(0, wordCount).join(" ");
+ const normalizedPrefix = prefix.toLowerCase();
+ const matchesPath = /[\\/]/.test(prefix)
+ ? selectedLabel.toLowerCase().includes(normalizedPrefix)
+ : pathComponents.some((component) => component.toLowerCase().includes(normalizedPrefix));
+ if (matchesPath) return prefix;
+ }
+ return "";
+}
+
+/**
+ * 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,
+ selectionKind: ComposerSelectionKind = "mention",
+): ComposerTrigger {
+ const selectedLabel = label.trim();
+ if (trigger.type !== "at" || !selectedLabel) return trigger;
+
+ const candidateLabels = [selectedLabel];
+ const addCandidateLabel = (candidate: string) => {
+ if (!candidate || candidateLabels.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return;
+ candidateLabels.push(candidate);
+ };
+ // File suggestions can be selected from a basename or a shorter path
+ // suffix even though the row inserts the full path. Treat those as the
+ // selected prefix so prose after the shorthand remains outside the splice.
+ addCandidateLabel(composerPathBasename(selectedLabel));
+ const searchableQuery = composerFileSearchQuery(trigger.query);
+ if (searchableQuery !== trigger.query && selectedLabel.toLowerCase().endsWith(searchableQuery.toLowerCase())) {
+ addCandidateLabel(searchableQuery);
+ }
+ // Extensionless path searches accept the longest space-delimited prefix that
+ // exists in the index, so the selected canonical path may continue beyond
+ // what the user typed before adding prose (for example, `src/my review`
+ // selecting `src/my folder`, or `my review` selecting `src/my/file`). Match
+ // against the full path for path-like prefixes and each component for
+ // basename/intermediate-component prefixes, without applying this heuristic
+ // to ordinary chat titles.
+ addCandidateLabel(composerPathPrefixForSelection(
+ trigger.query,
+ selectedLabel,
+ selectionKind === "file",
+ ));
+
+ for (const candidateLabel of candidateLabels) {
+ const matchedPrefix = trigger.query.slice(0, candidateLabel.length);
+ if (matchedPrefix.toLowerCase() !== candidateLabel.toLowerCase()) continue;
+
+ const remainder = trigger.query.slice(candidateLabel.length);
+ if (remainder.length > 0 && !/^[ \t]/.test(remainder)) continue;
+ const separator = remainder.match(/^[ \t]*/)?.[0] ?? "";
+ return {
+ ...trigger,
+ query: `${matchedPrefix}${separator}`,
+ };
+ }
+ return trigger;
+}
+
/**
* Replace exactly the trigger span (trigger character through the end of the
* typed query) with `insertion`, leaving surrounding text untouched so
@@ -63,7 +162,35 @@ export type ComposerTokenRange = {
kind: ComposerTokenKind;
};
-const CONFIRMED_TOKEN_RE = /(^|\s)([@/])(\S+)/g;
+const CONFIRMED_TRIGGER_RE = /(^|\s)([@/])/g;
+
+function plainComposerTokenEnd(text: string, start: number, limit: number): number | null {
+ let end = start;
+ while (end < limit && !/\s/.test(text[end]!)) end += 1;
+ return end > start ? end : null;
+}
+
+function confirmedFileTokenEnd(
+ text: string,
+ start: number,
+ limit: number,
+ isFile: (body: string) => boolean,
+): number | null {
+ let end = start;
+ let bestEnd: number | null = null;
+ while (end < limit) {
+ const character = text[end]!;
+ if (character === "\r" || character === "\n") break;
+ if (character === " " || character === "\t") {
+ if (isFile(text.slice(start, end))) bestEnd = end;
+ end += 1;
+ continue;
+ }
+ end += 1;
+ }
+ if (end === limit && end > start && isFile(text.slice(start, end))) bestEnd = end;
+ return bestEnd;
+}
/**
* Find the confirmed chip tokens in a draft: word-boundary `@body` / `/body`
@@ -87,17 +214,51 @@ export function findConfirmedComposerTokens(
): ComposerTokenRange[] {
if (!text) return [];
const tokens: ComposerTokenRange[] = [];
- for (const match of text.matchAll(CONFIRMED_TOKEN_RE)) {
+ for (const match of text.matchAll(CONFIRMED_TRIGGER_RE)) {
const start = (match.index ?? 0) + match[1]!.length;
- const body = match[3]!;
- const kind: ComposerTokenKind | null = match[2] === "@"
- ? (confirm.isMention?.(body) ? "mention" : confirm.isFile(body) ? "file" : null)
- : (confirm.isCommand(body) ? "command" : null);
- if (kind) tokens.push({ start, end: start + 1 + body.length, kind });
+ const bodyStart = start + 1;
+ const plainEnd = plainComposerTokenEnd(text, bodyStart, text.length);
+ const plainBody = plainEnd == null ? "" : text.slice(bodyStart, plainEnd);
+ if (match[2] === "@") {
+ if (plainBody && confirm.isMention?.(plainBody)) {
+ tokens.push({ start, end: plainEnd!, kind: "mention" });
+ continue;
+ }
+ const fileEnd = confirmedFileTokenEnd(text, bodyStart, text.length, confirm.isFile);
+ if (fileEnd != null) tokens.push({ start, end: fileEnd, kind: "file" });
+ } else if (plainBody && confirm.isCommand(plainBody)) {
+ tokens.push({ start, end: plainEnd!, kind: "command" });
+ }
}
return tokens;
}
+/**
+ * True when an @ trigger begins with an already-confirmed file or mention
+ * token and the user has typed a separator after it. Confirmed tokens are
+ * complete attachments/pointers, not new searches, so the menu must stay
+ * closed while the user continues ordinary prose after them.
+ */
+export function composerTriggerHasConfirmedPrefix(
+ text: string,
+ trigger: Pick,
+ confirm: {
+ isFile: (body: string) => boolean;
+ isMention?: (body: string) => boolean;
+ },
+): boolean {
+ if (trigger.type !== "at") return false;
+ const end = Math.min(text.length, trigger.start + 1 + trigger.query.length);
+ const bodyStart = trigger.start + 1;
+ const plainEnd = plainComposerTokenEnd(text, bodyStart, end);
+ if (plainEnd != null && plainEnd < end) {
+ const body = text.slice(bodyStart, plainEnd);
+ if (confirm.isFile(body) || confirm.isMention?.(body) === true) return true;
+ }
+ const fileEnd = confirmedFileTokenEnd(text, bodyStart, end, confirm.isFile);
+ return fileEnd != null && fileEnd < end && /[ \t]/.test(text[fileEnd]!);
+}
+
/** True when the trigger token is the only content in the draft. */
export function composerTriggerSpansWholeDraft(
text: string,
diff --git a/apps/desktop/src/shared/types/files.ts b/apps/desktop/src/shared/types/files.ts
index 467d8121c..c450afe49 100644
--- a/apps/desktop/src/shared/types/files.ts
+++ b/apps/desktop/src/shared/types/files.ts
@@ -246,6 +246,8 @@ export type FilesQuickOpenArgs = {
query: string;
limit?: number;
includeIgnored?: boolean;
+ /** Allow the composer-only path-prefix match for trailing prose. */
+ allowComposerPrefixFallback?: boolean;
};
export type FilesQuickOpenItem = {
diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts
index 872dcb7a0..d218201c6 100644
--- a/apps/desktop/src/shared/types/sync.ts
+++ b/apps/desktop/src/shared/types/sync.ts
@@ -1336,7 +1336,7 @@ export type SyncFileRequest =
| { action: "deletePath"; args: { workspaceId: string; path: string } }
| { action: "watchChanges"; args: { workspaceId: string; includeIgnored?: boolean } }
| { action: "stopWatching"; args: { workspaceId: string; includeIgnored?: boolean } }
- | { action: "quickOpen"; args: { workspaceId: string; query: string; limit?: number; includeIgnored?: boolean } }
+ | { action: "quickOpen"; args: { workspaceId: string; query: string; limit?: number; includeIgnored?: boolean; allowComposerPrefixFallback?: boolean } }
| { action: "searchText"; args: { workspaceId: string; query: string; limit?: number; includeIgnored?: boolean } }
| { action: "readArtifact"; args: { artifactId?: string; uri?: string; path?: string } };
diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift
index 7e5fcfe46..675dd5428 100644
--- a/apps/ios/ADE/Services/SyncService.swift
+++ b/apps/ios/ADE/Services/SyncService.swift
@@ -22,6 +22,28 @@ enum WidgetReloadBridge {
private let syncConnectLog = Logger(subsystem: "com.ade.sync", category: "connect")
private let syncChatLog = Logger(subsystem: "com.ade.ios", category: "WorkChatSync")
+/// Build the host request payload for file quick-open. The composer-only
+/// prefix fallback is intentionally omitted for generic Files searches so a
+/// multiword query keeps its normal exact-search semantics everywhere else.
+func syncQuickOpenRequestArgs(
+ workspaceId: String,
+ query: String,
+ limit: Int,
+ includeIgnored: Bool,
+ allowComposerPrefixFallback: Bool
+) -> [String: Any] {
+ var args: [String: Any] = [
+ "workspaceId": workspaceId,
+ "query": query,
+ "limit": limit,
+ "includeIgnored": includeIgnored,
+ ]
+ if allowComposerPrefixFallback {
+ args["allowComposerPrefixFallback"] = true
+ }
+ return args
+}
+
/// Transport-level attachment to a machine. There is deliberately no separate
/// "hydrating"/"syncing" state: attachment is a fact the moment `hello_ok` is
/// applied, and per-domain hydration progress is carried by `SyncDomainStatus`
@@ -9925,16 +9947,21 @@ final class SyncService: ObservableObject {
workspaceId: String,
query: String,
limit: Int = 30,
- includeIgnored: Bool = true
+ includeIgnored: Bool = true,
+ allowComposerPrefixFallback: Bool = false
) async throws -> [FilesQuickOpenItem] {
let boundedLimit = min(max(limit, 1), 1000)
return try decode(
- try await sendFileRequest(action: "quickOpen", args: [
- "workspaceId": workspaceId,
- "query": query,
- "limit": boundedLimit,
- "includeIgnored": includeIgnored,
- ]),
+ try await sendFileRequest(
+ action: "quickOpen",
+ args: syncQuickOpenRequestArgs(
+ workspaceId: workspaceId,
+ query: query,
+ limit: boundedLimit,
+ includeIgnored: includeIgnored,
+ allowComposerPrefixFallback: allowComposerPrefixFallback
+ )
+ ),
as: [FilesQuickOpenItem].self
)
}
diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
index 32829ea9d..a4cc0d9ad 100644
--- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
+++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift
@@ -152,14 +152,19 @@ 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]*)$")
+ private static let fileQueryRegex = try! NSRegularExpression(
+ pattern: "^(.+?\\.[A-Za-z0-9_-]+)(?:[ \\t]+.*)?$"
+ )
static func detect(in text: NSString, cursor: Int) -> WorkComposerTriggerMatch? {
guard cursor >= 0, cursor <= text.length else { return nil }
@@ -192,6 +197,126 @@ enum WorkComposerTriggerDetector {
return nil
}
}
+
+ /// 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 extensionless path prefixes separate from prose when the selected
+ /// file's canonical path continues past the words typed before that prose.
+ static func pathPrefixForSelection(query: String, selectedLabel: String) -> String {
+ let words = query.split(whereSeparator: { $0 == " " || $0 == "\t" })
+ guard words.count > 1 else { return "" }
+ let pathComponents = selectedLabel
+ .split(whereSeparator: { $0 == "/" || $0 == "\\" })
+ .map(String.init)
+
+ for wordCount in stride(from: words.count - 1, through: 1, by: -1) {
+ let prefix = words.prefix(wordCount).joined(separator: " ")
+ let matchesPath: Bool
+ if prefix.contains("/") || prefix.contains("\\") {
+ matchesPath = selectedLabel.lowercased().contains(prefix.lowercased())
+ } else {
+ matchesPath = pathComponents.contains { $0.lowercased().contains(prefix.lowercased()) }
+ }
+ if matchesPath { return prefix }
+ }
+ return ""
+ }
+
+ /// A committed @ chip is complete once the following character is whitespace.
+ /// The live detector still accepts spaces for multiword queries, so the chip
+ /// range must explicitly terminate that otherwise ambiguous trigger.
+ static func hasConfirmedChipPrefix(
+ _ match: WorkComposerTriggerMatch,
+ in text: NSString,
+ chipRanges: [NSRange]
+ ) -> Bool {
+ guard match.kind == .at else { return false }
+ let matchEnd = NSMaxRange(match.range)
+ for chipRange in chipRanges {
+ guard chipRange.location == match.range.location,
+ chipRange.location >= 0,
+ NSMaxRange(chipRange) <= matchEnd,
+ NSMaxRange(chipRange) < text.length,
+ text.character(at: chipRange.location) == 0x40 else { continue }
+ let following = text.character(at: NSMaxRange(chipRange))
+ if following == 0x20 || following == 0x09 || following == 0x0A || following == 0x0D {
+ return true
+ }
+ }
+ return false
+ }
+
+ /// 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 }
+
+ var candidateLabels = [label]
+ if let basename = label.split(whereSeparator: { $0 == "/" || $0 == "\\" }).last {
+ let basenameString = String(basename)
+ if !candidateLabels.contains(where: { $0.lowercased() == basenameString.lowercased() }) {
+ candidateLabels.append(basenameString)
+ }
+ }
+ let searchableQuery = fileSearchQuery(for: match.query)
+ if searchableQuery != match.query,
+ label.lowercased().hasSuffix(searchableQuery.lowercased()),
+ !candidateLabels.contains(where: { $0.lowercased() == searchableQuery.lowercased() }) {
+ candidateLabels.append(searchableQuery)
+ }
+ let pathPrefix = pathPrefixForSelection(query: match.query, selectedLabel: label)
+ if !pathPrefix.isEmpty,
+ !candidateLabels.contains(where: { $0.lowercased() == pathPrefix.lowercased() }) {
+ candidateLabels.append(pathPrefix)
+ }
+
+ let query = match.query as NSString
+ for candidateLabel in candidateLabels {
+ let labelLength = (candidateLabel as NSString).length
+ guard query.length >= labelLength else { continue }
+ let prefix = query.substring(to: labelLength)
+ guard prefix.lowercased() == candidateLabel.lowercased() else { continue }
+
+ let remainder = query.substring(from: labelLength) as NSString
+ guard remainder.length == 0 || remainder.character(at: 0) == 0x20 || remainder.character(at: 0) == 0x09 else {
+ continue
+ }
+ 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)
+ )
+ }
+ return match
+ }
}
// MARK: - Suggestion model
@@ -292,7 +417,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
}
@@ -360,13 +485,14 @@ 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))
}
}
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()
}
@@ -416,7 +542,8 @@ final class WorkComposerSuggestionController: ObservableObject {
workspaceId: workspaceId,
query: query,
limit: 20,
- includeIgnored: true
+ includeIgnored: true,
+ allowComposerPrefixFallback: true
)
guard !Task.isCancelled, self.laneGeneration == generation else { return }
let mapped = items.map { item -> WorkComposerSuggestion in
@@ -1180,8 +1307,15 @@ struct WorkComposerTextView: UIViewRepresentable {
in: textView.text as NSString,
cursor: selection.location
)
- applyPromptInputTraits(protectingTrigger: match != nil)
- parent.controller.update(match: match)
+ let resolvedMatch = match.flatMap { candidate in
+ WorkComposerTriggerDetector.hasConfirmedChipPrefix(
+ candidate,
+ in: textView.text as NSString,
+ chipRanges: chips.map { $0.range }
+ ) ? nil : candidate
+ }
+ applyPromptInputTraits(protectingTrigger: resolvedMatch != nil)
+ parent.controller.update(match: resolvedMatch)
}
func commit(_ suggestion: WorkComposerSuggestion, replacing range: NSRange) {
diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift
index 6819fb1ab..bea8bf4de 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,219 @@ 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 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 testAtSelectionRangePreservesTrailingProseAfterShorthandFileQuery() {
+ let text = "ask @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, "foo.swift ")
+ XCTAssertEqual(
+ narrowed.range,
+ NSRange(location: 4, length: ("@foo.swift " as NSString).length)
+ )
+ }
+
+ func testAtSelectionRangePreservesTrailingProseAfterExtensionlessPathPrefix() {
+ let text = "ask @src/my review this"
+ let match = try! XCTUnwrap(detect(text))
+ let suggestion = WorkComposerSuggestion(
+ id: "file:src/my folder",
+ kind: .at,
+ title: "my folder",
+ subtitle: "src",
+ insertText: "@src/my folder"
+ )
+
+ let narrowed = WorkComposerTriggerDetector.matchForSelection(match, suggestion: suggestion)
+ XCTAssertEqual(narrowed.query, "src/my ")
+ XCTAssertEqual(
+ narrowed.range,
+ NSRange(location: 4, length: ("@src/my " as NSString).length)
+ )
+ XCTAssertEqual(
+ (text as NSString).replacingCharacters(in: narrowed.range, with: suggestion.insertText + " "),
+ "ask @src/my folder review this"
+ )
+ }
+
+ func testAtSelectionRangePreservesTrailingProseAfterRootLevelSpacedFilePrefix() {
+ let text = "ask @my review this"
+ let match = try! XCTUnwrap(detect(text))
+ let suggestion = WorkComposerSuggestion(
+ id: "file:my file",
+ kind: .at,
+ title: "my file",
+ subtitle: nil,
+ insertText: "@my file"
+ )
+
+ let narrowed = WorkComposerTriggerDetector.matchForSelection(match, suggestion: suggestion)
+ XCTAssertEqual(narrowed.query, "my ")
+ XCTAssertEqual(
+ narrowed.range,
+ NSRange(location: 4, length: ("@my " as NSString).length)
+ )
+ XCTAssertEqual(
+ (text as NSString).replacingCharacters(in: narrowed.range, with: suggestion.insertText + " "),
+ "ask @my file review this"
+ )
+ }
+
+ func testAtSelectionRangePreservesTrailingProseAfterIntermediatePathComponent() {
+ let text = "ask @my review this"
+ let match = try! XCTUnwrap(detect(text))
+ let suggestion = WorkComposerSuggestion(
+ id: "file:src/my/file",
+ kind: .at,
+ title: "file",
+ subtitle: "src/my",
+ insertText: "@src/my/file"
+ )
+
+ let narrowed = WorkComposerTriggerDetector.matchForSelection(match, suggestion: suggestion)
+ XCTAssertEqual(narrowed.query, "my ")
+ XCTAssertEqual(
+ (text as NSString).replacingCharacters(in: narrowed.range, with: suggestion.insertText + " "),
+ "ask @src/my/file review this"
+ )
+ }
+
+ func testAtSelectionRangePreservesTrailingProseAfterNestedBasenamePrefix() {
+ let text = "ask @my review this"
+ let match = try! XCTUnwrap(detect(text))
+ let suggestion = WorkComposerSuggestion(
+ id: "file:docs/my file",
+ kind: .at,
+ title: "my file",
+ subtitle: "docs",
+ insertText: "@docs/my file"
+ )
+
+ let narrowed = WorkComposerTriggerDetector.matchForSelection(match, suggestion: suggestion)
+ XCTAssertEqual(narrowed.query, "my ")
+ XCTAssertEqual(
+ (text as NSString).replacingCharacters(in: narrowed.range, with: suggestion.insertText + " "),
+ "ask @docs/my file review this"
+ )
+ }
+
+ func testAtSelectionRangePreservesTrailingProseAfterSubstringPathComponent() {
+ let text = "ask @ead review this"
+ let match = try! XCTUnwrap(detect(text))
+ let suggestion = WorkComposerSuggestion(
+ id: "file:docs/README",
+ kind: .at,
+ title: "README",
+ subtitle: "docs",
+ insertText: "@docs/README"
+ )
+
+ let narrowed = WorkComposerTriggerDetector.matchForSelection(match, suggestion: suggestion)
+ XCTAssertEqual(narrowed.query, "ead ")
+ XCTAssertEqual(
+ (text as NSString).replacingCharacters(in: narrowed.range, with: suggestion.insertText + " "),
+ "ask @docs/README review this"
+ )
+ }
+
+ func testConfirmedFileChipTerminatesBeforeTrailingProse() {
+ let text = "ask @src/foo.swift about this" as NSString
+ let match = try! XCTUnwrap(detect(text as String))
+ let chipRange = NSRange(location: 4, length: ("@src/foo.swift" as NSString).length)
+
+ XCTAssertTrue(
+ WorkComposerTriggerDetector.hasConfirmedChipPrefix(
+ match,
+ in: text,
+ chipRanges: [chipRange]
+ )
+ )
+ XCTAssertFalse(
+ WorkComposerTriggerDetector.hasConfirmedChipPrefix(
+ try! XCTUnwrap(detect("ask @src/foo.swift")),
+ in: "ask @src/foo.swift" as NSString,
+ chipRanges: [chipRange]
+ )
+ )
+ }
+
+ 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: "src/my folder about this"),
+ "src/my folder about this"
+ )
+ XCTAssertEqual(
+ WorkComposerTriggerDetector.fileSearchQuery(for: "a b c"),
+ "a b c"
+ )
+ }
+
+ func testComposerQuickOpenRequestEnablesPrefixFallbackWithoutChangingGenericSearch() {
+ let generic = syncQuickOpenRequestArgs(
+ workspaceId: "workspace-1",
+ query: "src/my folder review this",
+ limit: 20,
+ includeIgnored: true,
+ allowComposerPrefixFallback: false
+ )
+ XCTAssertNil(generic["allowComposerPrefixFallback"])
+
+ let composer = syncQuickOpenRequestArgs(
+ workspaceId: "workspace-1",
+ query: "src/my folder review this",
+ limit: 20,
+ includeIgnored: true,
+ allowComposerPrefixFallback: true
+ )
+ XCTAssertEqual(composer["allowComposerPrefixFallback"] as? Bool, true)
+ }
+
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..49826c2a6 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 `@`; 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
@@ -1513,7 +1517,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..8b3f9cd76 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 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. |
| `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..83aa97720 100644
--- a/docs/features/terminals-and-sessions/README.md
+++ b/docs/features/terminals-and-sessions/README.md
@@ -1267,12 +1267,18 @@ 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 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
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..bbd87dba9 100644
--- a/docs/features/terminals-and-sessions/ui-surfaces.md
+++ b/docs/features/terminals-and-sessions/ui-surfaces.md
@@ -278,7 +278,12 @@ 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. 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