diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 32a8e309beb..5628826a39f 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -78,6 +78,7 @@ import {
collapseExpandedComposerCursor,
parseStandaloneComposerSlashCommand,
} from "../composer-logic";
+import { resolveComposerMentionFileTarget } from "../composerMentionFileTarget";
import {
derivePendingApprovals,
derivePendingUserInputs,
@@ -3267,6 +3268,17 @@ function ChatViewContent(props: ChatViewProps) {
},
[activeProject, activeThreadRef],
);
+ // A mention chip opens its file the same way a rendered chat file link does,
+ // so a reference you just wrote is readable before you send it.
+ const openMentionFileSurface = useCallback(
+ (mentionPath: string) => {
+ if (!activeThreadRef || !activeProject) return;
+ const target = resolveComposerMentionFileTarget(mentionPath, activeWorkspaceRoot);
+ if (!target) return;
+ useRightPanelStore.getState().openFile(activeThreadRef, target.relativePath, target.line);
+ },
+ [activeProject, activeThreadRef, activeWorkspaceRoot],
+ );
// The thread's own change request, placed against the project it belongs to. Without a
// project there is nothing to resolve it against, so the caller falls back to the browser.
const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null;
@@ -6352,6 +6364,7 @@ function ChatViewContent(props: ChatViewProps) {
keybindings={keybindings}
terminalOpen={Boolean(terminalUiState.terminalOpen)}
gitCwd={gitCwd}
+ onOpenMentionFile={openMentionFileSurface}
promptRef={promptRef}
composerImagesRef={composerImagesRef}
composerTerminalContextsRef={composerTerminalContextsRef}
diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx
index 0489e8c79cd..8d215950001 100644
--- a/apps/web/src/components/ComposerPromptEditor.tsx
+++ b/apps/web/src/components/ComposerPromptEditor.tsx
@@ -128,22 +128,56 @@ type SerializedComposerTerminalContextNode = Spread<
SerializedLexicalNode
>;
-const ComposerTerminalContextActionsContext = createContext<{
+const ComposerChipActionsContext = createContext<{
onRemoveTerminalContext: (contextId: string) => void;
+ // Only a host that can show the mentioned file (the chat surface, via its
+ // right panel) supplies this; standalone editors like the appearance preview
+ // leave it null and their mention chips stay inert.
+ onOpenMentionFile: ((path: string) => void) | null;
}>({
onRemoveTerminalContext: () => {},
+ onOpenMentionFile: null,
});
function ComposerMentionDecorator(props: { path: string }) {
const theme = resolvedThemeFromDocument();
+ const { onOpenMentionFile } = use(ComposerChipActionsContext);
+ const path = props.path;
const chip = (
) => {
+ event.preventDefault();
+ },
+ onClick: (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onOpenMentionFile(path);
+ },
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ event.stopPropagation();
+ onOpenMentionFile(path);
+ },
+ }
+ : {})}
>
-
+
);
@@ -151,7 +185,7 @@ function ComposerMentionDecorator(props: { path: string }) {
- {props.path}
+ {path}
);
@@ -884,6 +918,7 @@ interface ComposerPromptEditorProps {
disabled: boolean;
placeholder: string;
className?: string;
+ onOpenMentionFile?: (path: string) => void;
onRemoveTerminalContext: (contextId: string) => void;
onChange: (
nextValue: string,
@@ -1105,7 +1140,7 @@ function ComposerInlineTokenSelectionNormalizePlugin() {
function ComposerInlineTokenBackspacePlugin() {
const [editor] = useLexicalComposerContext();
- const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext);
+ const { onRemoveTerminalContext } = use(ComposerChipActionsContext);
useEffect(() => {
return editor.registerCommand(
@@ -1533,6 +1568,7 @@ function ComposerPromptEditorInner({
disabled,
placeholder,
className,
+ onOpenMentionFile,
onRemoveTerminalContext,
onChange,
onCommandKeyDown,
@@ -1554,9 +1590,9 @@ function ComposerPromptEditorInner({
terminalContextIds: terminalContexts.map((context) => context.id),
});
const isApplyingControlledUpdateRef = useRef(false);
- const terminalContextActions = useMemo(
- () => ({ onRemoveTerminalContext }),
- [onRemoveTerminalContext],
+ const chipActions = useMemo(
+ () => ({ onRemoveTerminalContext, onOpenMentionFile: onOpenMentionFile ?? null }),
+ [onOpenMentionFile, onRemoveTerminalContext],
);
useEffect(() => {
@@ -1746,7 +1782,7 @@ function ComposerPromptEditorInner({
}, []);
return (
-
+
-
+
);
}
@@ -1795,6 +1831,7 @@ export function ComposerPromptEditor({
disabled,
placeholder,
className,
+ onOpenMentionFile,
onRemoveTerminalContext,
onChange,
onCommandKeyDown,
@@ -1836,6 +1873,7 @@ export function ComposerPromptEditor({
onChange={onChange}
onPaste={onPaste}
editorRef={editorRef}
+ {...(onOpenMentionFile ? { onOpenMentionFile } : {})}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(className ? { className } : {})}
/>
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index e918e775868..d000206cc51 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -557,6 +557,8 @@ export interface ChatComposerProps {
keybindings: ResolvedKeybindingsConfig;
terminalOpen: boolean;
gitCwd: string | null;
+ /** Shows a mention chip's file in the right panel; absent when there is no panel to show it in. */
+ onOpenMentionFile?: (path: string) => void;
// Refs the parent needs kept in sync
promptRef: React.RefObject;
@@ -643,6 +645,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
keybindings,
terminalOpen,
gitCwd,
+ onOpenMentionFile,
promptRef,
composerRef,
composerImagesRef,
@@ -3034,6 +3037,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
skills={selectedProviderStatus?.skills ?? []}
{...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
+ {...(onOpenMentionFile ? { onOpenMentionFile } : {})}
onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
onChange={onPromptChange}
onCommandKeyDown={onComposerCommandKey}
diff --git a/apps/web/src/composerMentionFileTarget.test.ts b/apps/web/src/composerMentionFileTarget.test.ts
new file mode 100644
index 00000000000..338730dd43b
--- /dev/null
+++ b/apps/web/src/composerMentionFileTarget.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveComposerMentionFileTarget } from "./composerMentionFileTarget";
+
+const ROOT = "/Users/dev/t3code";
+
+describe("resolveComposerMentionFileTarget", () => {
+ it("passes workspace-relative mentions straight through", () => {
+ expect(resolveComposerMentionFileTarget("AGENTS.md", ROOT)).toEqual({
+ relativePath: "AGENTS.md",
+ });
+ expect(resolveComposerMentionFileTarget("apps/web/src/main.tsx", ROOT)).toEqual({
+ relativePath: "apps/web/src/main.tsx",
+ });
+ });
+
+ it("relativizes absolute mentions inside the workspace", () => {
+ expect(resolveComposerMentionFileTarget(`${ROOT}/apps/web/src/main.tsx`, ROOT)).toEqual({
+ relativePath: "apps/web/src/main.tsx",
+ });
+ });
+
+ it("keeps extensionless filenames, which markdown link heuristics reject", () => {
+ expect(resolveComposerMentionFileTarget("Makefile", ROOT)).toEqual({
+ relativePath: "Makefile",
+ });
+ });
+
+ it("collapses dot segments", () => {
+ expect(resolveComposerMentionFileTarget("./docs/../AGENTS.md", ROOT)).toEqual({
+ relativePath: "AGENTS.md",
+ });
+ });
+
+ it("carries a :line suffix over as the reveal line", () => {
+ expect(resolveComposerMentionFileTarget("apps/web/src/main.tsx:42", ROOT)).toEqual({
+ relativePath: "apps/web/src/main.tsx",
+ line: 42,
+ });
+ expect(resolveComposerMentionFileTarget("apps/web/src/main.tsx:42:7", ROOT)).toEqual({
+ relativePath: "apps/web/src/main.tsx",
+ line: 42,
+ });
+ });
+
+ it("normalizes windows separators", () => {
+ expect(resolveComposerMentionFileTarget("C:\\repo\\apps\\web\\main.tsx", "C:\\repo")).toEqual({
+ relativePath: "apps/web/main.tsx",
+ });
+ });
+
+ it("works for a workspace rooted at the filesystem root", () => {
+ expect(resolveComposerMentionFileTarget("/srv/app/main.ts", "/")).toEqual({
+ relativePath: "srv/app/main.ts",
+ });
+ });
+
+ // Folding case here would accept a path from outside the workspace on a
+ // case-sensitive filesystem.
+ it("does not accept a root whose case does not match", () => {
+ expect(resolveComposerMentionFileTarget("/users/dev/t3code/Other.ts", ROOT)).toBeNull();
+ });
+
+ // The filesystem behind a drive root does not distinguish these.
+ it("matches a windows root case-insensitively", () => {
+ expect(resolveComposerMentionFileTarget("C:\\Repo\\src\\file.ts", "C:\\repo")).toEqual({
+ relativePath: "src/file.ts",
+ });
+ });
+
+ it("still matches a windows root whose drive letter case differs", () => {
+ expect(resolveComposerMentionFileTarget("c:\\repo\\apps\\main.tsx", "C:\\repo")).toEqual({
+ relativePath: "apps/main.tsx",
+ });
+ });
+
+ // `/..` is `/`, so climbing past an absolute root cannot leave `..` behind
+ // for the file surface to walk.
+ it("does not let a path climb above the root it resolves against", () => {
+ expect(resolveComposerMentionFileTarget("../a/secret.ts", "/")).toEqual({
+ relativePath: "a/secret.ts",
+ });
+ expect(resolveComposerMentionFileTarget("/x/../../a/secret.ts", "/")).toEqual({
+ relativePath: "a/secret.ts",
+ });
+ });
+
+ it("returns null outside the workspace, and without one", () => {
+ expect(resolveComposerMentionFileTarget("/etc/hosts", ROOT)).toBeNull();
+ expect(resolveComposerMentionFileTarget("../sibling/AGENTS.md", ROOT)).toBeNull();
+ expect(resolveComposerMentionFileTarget("AGENTS.md", undefined)).toBeNull();
+ expect(resolveComposerMentionFileTarget(" ", ROOT)).toBeNull();
+ });
+});
diff --git a/apps/web/src/composerMentionFileTarget.ts b/apps/web/src/composerMentionFileTarget.ts
new file mode 100644
index 00000000000..4aa60728e8d
--- /dev/null
+++ b/apps/web/src/composerMentionFileTarget.ts
@@ -0,0 +1,64 @@
+import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links";
+
+export interface ComposerMentionFileTarget {
+ readonly relativePath: string;
+ readonly line?: number;
+}
+
+function toPosixPath(path: string): string {
+ const normalized = path.replaceAll("\\", "/");
+ // Windows drive paths pick up a leading slash on the way through URL-ish
+ // plumbing (`/C:/repo`); the workspace root never carries one.
+ return /^\/[A-Za-z]:\//.test(normalized) ? normalized.slice(1) : normalized;
+}
+
+const WINDOWS_DRIVE_ROOT_PATTERN = /^[A-Za-z]:\//;
+
+function collapseDotSegments(path: string): string {
+ const isAbsolute = path.startsWith("/");
+ const segments: string[] = [];
+ for (const segment of path.split("/")) {
+ if (segment === "" || segment === ".") continue;
+ if (segment === "..") {
+ if (segments.length > 0 && segments[segments.length - 1] !== "..") {
+ segments.pop();
+ continue;
+ }
+ // `/..` is `/`; a surviving `..` would walk out of the workspace.
+ if (isAbsolute) continue;
+ }
+ segments.push(segment);
+ }
+ const joined = segments.join("/");
+ return isAbsolute ? `/${joined}` : joined;
+}
+
+/** Null for anything outside the workspace, which leaves the chip inert. */
+export function resolveComposerMentionFileTarget(
+ mentionPath: string,
+ workspaceRoot: string | undefined,
+): ComposerMentionFileTarget | null {
+ const trimmed = mentionPath.trim();
+ if (!trimmed || !workspaceRoot) return null;
+
+ const { path, line } = splitPathAndPosition(trimmed);
+ if (!path) return null;
+
+ const absolute = collapseDotSegments(toPosixPath(resolvePathLinkTarget(path, workspaceRoot)));
+ const normalizedRoot = collapseDotSegments(toPosixPath(workspaceRoot));
+ // A `/` root strips to an empty prefix, which is what makes `/x` read as `x`.
+ const root = normalizedRoot === "/" ? "" : normalizedRoot.replace(/\/+$/, "");
+ if (!root && normalizedRoot !== "/") return null;
+ // Compared the way the root's filesystem would: exact for POSIX, folded for
+ // a Windows drive.
+ const rootIsCaseInsensitive = WINDOWS_DRIVE_ROOT_PATTERN.test(normalizedRoot);
+ const comparableAbsolute = rootIsCaseInsensitive ? absolute.toLowerCase() : absolute;
+ const comparableRoot = rootIsCaseInsensitive ? root.toLowerCase() : root;
+ if (!comparableAbsolute.startsWith(`${comparableRoot}/`)) return null;
+
+ const relativePath = absolute.slice(root.length + 1);
+ if (!relativePath) return null;
+
+ const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;
+ return Number.isFinite(parsedLine) ? { relativePath, line: parsedLine } : { relativePath };
+}