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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 22 additions & 16 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const next = replaceComposerTriggerSpan(prompt, trigger, `${suggestion.insertText} `);
setPromptValue(next.text, next.caret);
setSelectedMentions((prev) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,64 @@ describe("AgentChatComposer", () => {
expect(await screen.findByText("App.tsx")).toBeTruthy();
});

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

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

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

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

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

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

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

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

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

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

Expand Down
76 changes: 66 additions & 10 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
} from "../../../shared/types/orchestration";
import { getModelById, modelSupportsFastMode, type ProviderFamily } from "../../../shared/modelRegistry";
import {
composerTriggerForSelection,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
findConfirmedComposerTokens,
Expand Down Expand Up @@ -1853,6 +1854,12 @@ export function AgentChatComposer({
const richEditorRef = useRef<HTMLDivElement | null>(null);
const richSelectionRef = useRef<Range | null>(null);
const richInitializedRef = useRef(false);
// Plain textarea chips are painted by an overlay, while the serialized
// draft intentionally stores only the opaque mention pointer. Keep the
// selected row's title separately so the visible chip stays user-facing.
// This is a presentation cache only; send-time parsing still uses the
// canonical @chat:<id> token in `draft`.
const mentionLabelsRef = useRef<Map<string, string>>(new Map());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const lastSerializedDraftRef = useRef<string>("");
const lastPlainSelectionRef = useRef<number | null>(null);
const fileAddInProgressRef = useRef(false);
Expand Down Expand Up @@ -2004,12 +2011,30 @@ export function AgentChatComposer({
let pos = 0;
plainComposerTokens.forEach((token, index) => {
if (token.start > pos) segments.push(draft.slice(pos, token.start));
const tokenText = draft.slice(token.start, token.end);
const displayText = token.kind === "mention"
? mentionLabelsRef.current.get(tokenText)?.trim() || tokenText
: tokenText;
Comment on lines +2025 to +2027

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep overlay text aligned with the textarea value

In the default plain-text composer, the textarea becomes transparent and this overlay supplies every visible glyph, so replacing the serialized @chat:<id> text with an arbitrarily sized title makes the two layers wrap and position following text differently. After selecting a mention whose title differs in width from its token, the visible caret, selection, and subsequent prose can appear on a different character or line from where edits actually occur; preserve layout-equivalent text or render mentions without relying on a text-mirroring overlay.

Useful? React with 👍 / 👎.

const isLabeledMention = token.kind === "mention" && displayText !== tokenText;
segments.push(
<span
key={`chip-${index}-${token.start}`}
className="rounded-[4px] bg-violet-500/14 text-violet-100/92 shadow-[inset_0_0_0_1px_rgba(167,139,250,0.18)]"
>
{draft.slice(token.start, token.end)}
{isLabeledMention ? (
// Keep the textarea's canonical token as an invisible layout slot.
// The visible title is positioned inside that slot so a longer or
// shorter label cannot move the caret or following prose out of
// alignment with the real textarea value.
<span className="relative inline-block align-baseline" title={displayText}>
<span className="invisible whitespace-pre" data-composer-mention-layout>
{tokenText}
</span>
<span className="absolute inset-0 overflow-hidden text-ellipsis whitespace-nowrap" data-composer-mention-display>
{displayText}
</span>
</span>
) : displayText}
</span>,
);
pos = token.end;
Expand Down Expand Up @@ -2713,7 +2738,7 @@ export function AgentChatComposer({
// and flattens chips, so serialized indices cannot be mapped back onto DOM
// positions. Chips, <br>, 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();
Expand Down Expand Up @@ -2741,8 +2766,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;
Expand All @@ -2757,9 +2785,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 };
}, []);

Expand All @@ -2768,7 +2809,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;
Expand All @@ -2779,7 +2820,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);
Expand Down Expand Up @@ -3873,11 +3921,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} `);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Encode spaces in confirmed file tokens

When the selected path contains spaces, this inserts it verbatim (for example, @src/my folder ), but findConfirmedComposerTokens and composerTriggerHasConfirmedPrefix only read the first \S+ body (src/my) while attachedPaths contains the full path. Consequently the plain composer neither renders the attachment as a chip nor recognizes it as terminated, and typing prose reopens the @ menu so Enter can be intercepted. Fresh evidence beyond the earlier confirmed-token report is that the newly added spaced-path search and selection flow now deliberately produces these tokens; use a shared encoding/parser that can round-trip whitespace-containing paths.

AGENTS.md reference: AGENTS.md:L33-L35

Useful? React with 👍 / 👎.

onDraftChange(next.text);
restoreTextareaCaret(next.caret);
}
Expand All @@ -3886,16 +3939,19 @@ export function AgentChatComposer({
// A mention is a pointer, not an attachment: nothing is resolved or read
// now. The token is expanded into an <ade-mention> block at send time.
const token = formatChatMentionToken(item.mention.kind, item.mention.id);
mentionLabelsRef.current.set(token, item.mention.title);
if (useRichComposer) {
if (!replaceRichTriggerWith({
chipKind: "mention",
chipText: token,
chipLabel: item.mention.title,
triggerLabel: item.mention.title,
})) {
insertTextIntoRichEditor(`${token} `);
}
} else {
const next = replaceComposerTriggerSpan(draft, commandMenuTrigger, `${token} `);
const trigger = composerTriggerForSelection(commandMenuTrigger, item.mention.title);
const next = replaceComposerTriggerSpan(draft, trigger, `${token} `);
onDraftChange(next.text);
restoreTextareaCaret(next.caret);
}
Expand Down
Loading
Loading