Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
202 changes: 193 additions & 9 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
} from "@/browser/utils/chatCommands";
import { shouldTriggerAutoCompaction } from "@/browser/utils/compaction/shouldTriggerAutoCompaction";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { findAtMentionAtCursor } from "@/common/utils/atMentions";
import {
getSlashCommandSuggestions,
type SlashSuggestion,
Expand Down Expand Up @@ -184,7 +185,15 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const [input, setInput] = usePersistedState(storageKeys.inputKey, "", { listener: true });
const [isSending, setIsSending] = useState(false);
const [hideReviewsDuringSend, setHideReviewsDuringSend] = useState(false);
const [showAtMentionSuggestions, setShowAtMentionSuggestions] = useState(false);
const [atMentionSuggestions, setAtMentionSuggestions] = useState<SlashSuggestion[]>([]);
const atMentionDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const atMentionRequestIdRef = useRef(0);
const lastAtMentionWorkspaceIdRef = useRef<string | null>(null);
const lastAtMentionQueryRef = useRef<string | null>(null);
const lastAtMentionInputRef = useRef<string>(input);
const [showCommandSuggestions, setShowCommandSuggestions] = useState(false);

const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
const [providerNames, setProviderNames] = useState<string[]>([]);
const [toast, setToast] = useState<Toast | null>(null);
Expand Down Expand Up @@ -253,6 +262,26 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : [];
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelSelectorRef = useRef<ModelSelectorRef>(null);
const [atMentionCursorNonce, setAtMentionCursorNonce] = useState(0);
const lastAtMentionCursorRef = useRef<number | null>(null);
const handleAtMentionCursorActivity = useCallback(() => {
if (variant !== "workspace") {
return;
}

const el = inputRef.current;
if (!el) {
return;
}

const nextCursor = el.selectionStart ?? input.length;
if (lastAtMentionCursorRef.current === nextCursor) {
return;
}

lastAtMentionCursorRef.current = nextCursor;
setAtMentionCursorNonce((n) => n + 1);
}, [input.length, variant]);

// Draft state combines text input and image attachments
// Reviews are managed separately via props (persisted in pendingReviews state)
Expand Down Expand Up @@ -294,6 +323,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
listener: true,
}
);
const atMentionListId = useId();
const commandListId = useId();
const telemetry = useTelemetry();
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
Expand Down Expand Up @@ -650,6 +680,107 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when editingMessage changes
}, [editingMessage]);

// Watch input/cursor for @file mentions (workspace variant only)
useEffect(() => {
if (atMentionDebounceRef.current) {
clearTimeout(atMentionDebounceRef.current);
atMentionDebounceRef.current = null;
}

const inputChanged = lastAtMentionInputRef.current !== input;
lastAtMentionInputRef.current = input;

if (!api || variant !== "workspace" || !workspaceId) {
// Invalidate any in-flight completion request.
atMentionRequestIdRef.current++;
lastAtMentionWorkspaceIdRef.current = null;
lastAtMentionQueryRef.current = null;
setAtMentionSuggestions([]);
setShowAtMentionSuggestions(false);
return;
}

// Prefer slash command suggestions when the input is a command.
if (input.trimStart().startsWith("/")) {
// Invalidate any in-flight completion request.
atMentionRequestIdRef.current++;
lastAtMentionWorkspaceIdRef.current = null;
lastAtMentionQueryRef.current = null;
setAtMentionSuggestions([]);
setShowAtMentionSuggestions(false);
return;
}

const cursor = inputRef.current?.selectionStart ?? input.length;
const match = findAtMentionAtCursor(input, cursor);

if (!match || match.query.length === 0) {
// Invalidate any in-flight completion request.
atMentionRequestIdRef.current++;
lastAtMentionWorkspaceIdRef.current = null;
lastAtMentionQueryRef.current = null;
setAtMentionSuggestions([]);
setShowAtMentionSuggestions(false);
return;
}

// If the user is moving the caret and we aren't already showing suggestions, don't re-open.
if (!inputChanged && !showAtMentionSuggestions) {
return;
}

// Avoid refetching on caret movement within the same token/query.
if (
!inputChanged &&
lastAtMentionWorkspaceIdRef.current === workspaceId &&
lastAtMentionQueryRef.current === match.query
) {
return;
}

lastAtMentionWorkspaceIdRef.current = workspaceId;
lastAtMentionQueryRef.current = match.query;

const requestId = ++atMentionRequestIdRef.current;
atMentionDebounceRef.current = setTimeout(() => {
void (async () => {
try {
const result = await api.workspace.getFileCompletions({
workspaceId,
query: match.query,
limit: 20,
});

if (atMentionRequestIdRef.current !== requestId) {
return;
}

const nextSuggestions = result.paths.map((p) => ({
id: `file:${p}`,
display: p,
description: "File",
replacement: `@${p}`,
}));

setAtMentionSuggestions(nextSuggestions);
setShowAtMentionSuggestions(nextSuggestions.length > 0);
} catch {
if (atMentionRequestIdRef.current === requestId) {
setAtMentionSuggestions([]);
setShowAtMentionSuggestions(false);
}
}
})();
}, 150);

return () => {
if (atMentionDebounceRef.current) {
clearTimeout(atMentionDebounceRef.current);
atMentionDebounceRef.current = null;
}
};
}, [api, input, showAtMentionSuggestions, variant, workspaceId, atMentionCursorNonce]);

// Watch input for slash commands
useEffect(() => {
const suggestions = getSlashCommandSuggestions(input, { providerNames, variant });
Expand Down Expand Up @@ -929,7 +1060,37 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
[editingMessage, pushToast, setImageAttachments]
);

// Handle command selection
// Handle suggestion selection

const handleAtMentionSelect = useCallback(
(suggestion: SlashSuggestion) => {
const cursor = inputRef.current?.selectionStart ?? input.length;
const match = findAtMentionAtCursor(input, cursor);
if (!match) {
return;
}

const next =
input.slice(0, match.startIndex) + suggestion.replacement + input.slice(match.endIndex);

setInput(next);
setAtMentionSuggestions([]);
setShowAtMentionSuggestions(false);

requestAnimationFrame(() => {
const el = inputRef.current;
if (!el || el.disabled) {
return;
}

el.focus();
const newCursor = match.startIndex + suggestion.replacement.length;
el.selectionStart = newCursor;
el.selectionEnd = newCursor;
});
},
[input, setInput]
);
const handleCommandSelect = useCallback(
(suggestion: SlashSuggestion) => {
setInput(suggestion.replacement);
Expand Down Expand Up @@ -1546,11 +1707,11 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

// Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal)

// Don't handle keys if command suggestions are visible
// Don't handle keys if suggestions are visible
if (
showCommandSuggestions &&
commandSuggestions.length > 0 &&
COMMAND_SUGGESTION_KEYS.includes(e.key)
COMMAND_SUGGESTION_KEYS.includes(e.key) &&
((showCommandSuggestions && commandSuggestions.length > 0) ||
(showAtMentionSuggestions && atMentionSuggestions.length > 0))
) {
return; // Let CommandSuggestions handle it
}
Expand Down Expand Up @@ -1711,7 +1872,18 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
/>
)}

{/* Command suggestions - available in both variants */}
{/* File path suggestions (@src/foo.ts) - workspace variant only */}
<CommandSuggestions
suggestions={atMentionSuggestions}
onSelectSuggestion={handleAtMentionSelect}
onDismiss={() => setShowAtMentionSuggestions(false)}
isVisible={showAtMentionSuggestions}
ariaLabel="File path suggestions"
listId={atMentionListId}
anchorRef={variant === "creation" ? inputRef : undefined}
/>

{/* Slash command suggestions - available in both variants */}
{/* In creation mode, use portal (anchorRef) to escape overflow:hidden containers */}
<CommandSuggestions
suggestions={commandSuggestions}
Expand Down Expand Up @@ -1742,20 +1914,32 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
onChange={setInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onKeyUp={handleAtMentionCursorActivity}
onMouseUp={handleAtMentionCursorActivity}
onSelect={handleAtMentionCursorActivity}
onDragOver={handleDragOver}
onDrop={handleDrop}
onEscapeInNormalMode={handleEscapeInNormalMode}
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
suppressKeys={
showCommandSuggestions || showAtMentionSuggestions
? COMMAND_SUGGESTION_KEYS
: undefined
}
placeholder={placeholder}
disabled={!editingMessage && (disabled || isSendInFlight)}
aria-label={editingMessage ? "Edit your last message" : "Message Claude"}
aria-autocomplete="list"
aria-controls={
showCommandSuggestions && commandSuggestions.length > 0
? commandListId
: undefined
: showAtMentionSuggestions && atMentionSuggestions.length > 0
? atMentionListId
: undefined
}
aria-expanded={
(showCommandSuggestions && commandSuggestions.length > 0) ||
(showAtMentionSuggestions && atMentionSuggestions.length > 0)
}
aria-expanded={showCommandSuggestions && commandSuggestions.length > 0}
className={variant === "creation" ? "min-h-24" : undefined}
/>
{/* Token count - positioned to left of mic */}
Expand Down
10 changes: 10 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,16 @@ export const workspace = {
}),
output: ResultSchema(BashToolResultSchema, z.string()),
},
getFileCompletions: {
input: z
.object({
workspaceId: z.string(),
query: z.string(),
limit: z.number().int().positive().max(50).optional(),
})
.strict(),
output: z.object({ paths: z.array(z.string()) }),
},
// Subscriptions
onChat: {
input: z.object({ workspaceId: z.string() }),
Expand Down
95 changes: 95 additions & 0 deletions src/common/utils/atMentions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from "bun:test";

import { extractAtMentions, findAtMentionAtCursor } from "./atMentions";

describe("atMentions", () => {
describe("extractAtMentions", () => {
it("extracts basic @path mentions", () => {
expect(extractAtMentions("see @src/foo.ts")).toEqual([
{
token: "src/foo.ts",
path: "src/foo.ts",
},
]);
});

it("strips trailing punctuation", () => {
expect(extractAtMentions("see (@src/foo.ts), and @bar/baz.ts.")).toEqual([
{
token: "src/foo.ts",
path: "src/foo.ts",
},
{
token: "bar/baz.ts",
path: "bar/baz.ts",
},
]);
});

it("parses #L<start>-<end> ranges", () => {
expect(extractAtMentions("check @src/foo.ts#L1-3")).toEqual([
{
token: "src/foo.ts#L1-3",
path: "src/foo.ts",
range: { startLine: 1, endLine: 3 },
},
]);
});

it("records an error for unsupported fragments", () => {
const mentions = extractAtMentions("check @src/foo.ts#anchor");
expect(mentions).toHaveLength(1);
expect(mentions[0]?.path).toBe("src/foo.ts");
expect(mentions[0]?.range).toBeUndefined();
expect(mentions[0]?.rangeError).toContain("expected #L<start>-<end>");
});

it("does not match email addresses", () => {
expect(extractAtMentions("email foo@bar.com and see @src/foo.ts")).toEqual([
{
token: "src/foo.ts",
path: "src/foo.ts",
},
]);
});
});

describe("findAtMentionAtCursor", () => {
it("finds the active mention at cursor", () => {
const text = "see @src/fo";
expect(findAtMentionAtCursor(text, text.length)).toEqual({
startIndex: 4,
endIndex: text.length,
query: "src/fo",
});
});

it("supports leading punctuation before @", () => {
const text = "(@src/fo";
expect(findAtMentionAtCursor(text, text.length)).toEqual({
startIndex: 1,
endIndex: text.length,
query: "src/fo",
});
});

it("ignores word@word patterns", () => {
const text = "foo@bar";
expect(findAtMentionAtCursor(text, text.length)).toBeNull();
});

it("ignores tokens that already contain a fragment (#...)", () => {
const text = "@src/foo.ts#L1-3";
expect(findAtMentionAtCursor(text, text.length)).toBeNull();
});

it("excludes trailing punctuation from the match", () => {
const text = "see @src/foo.ts,";
expect(findAtMentionAtCursor(text, text.length)).toEqual({
startIndex: 4,
endIndex: 15,
query: "src/foo.ts",
});
});
});
});
Loading
Loading