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 }; +}