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
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
collapseExpandedComposerCursor,
parseStandaloneComposerSlashCommand,
} from "../composer-logic";
import { resolveComposerMentionFileTarget } from "../composerMentionFileTarget";
import {
derivePendingApprovals,
derivePendingUserInputs,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -6352,6 +6364,7 @@ function ChatViewContent(props: ChatViewProps) {
keybindings={keybindings}
terminalOpen={Boolean(terminalUiState.terminalOpen)}
gitCwd={gitCwd}
onOpenMentionFile={openMentionFileSurface}
promptRef={promptRef}
composerImagesRef={composerImagesRef}
composerTerminalContextsRef={composerTerminalContextsRef}
Expand Down
58 changes: 48 additions & 10 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,30 +128,64 @@ 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 = (
<span
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
className={FILE_TAG_CHIP_CLASS_NAME}
className={cn(
FILE_TAG_CHIP_CLASS_NAME,
onOpenMentionFile && "cursor-pointer transition-colors hover:bg-accent/70",
)}
contentEditable={false}
spellCheck={false}
data-composer-mention-chip="true"
{...(onOpenMentionFile
? {
role: "button",
// Focusable so opening a file is not pointer-only; Enter and Space
// are stopped before Lexical inserts them into the prompt.
tabIndex: 0,
Comment thread
cursor[bot] marked this conversation as resolved.
// Focus follows a click on a focusable element, which would blur
// the editor and leave typing going nowhere.
onMouseDown: (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
},
onClick: (event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
onOpenMentionFile(path);
},
onKeyDown: (event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onOpenMentionFile(path);
},
}
: {})}
>
<FileTagChipContent path={props.path} label={basenameOfPath(props.path)} theme={theme} />
<FileTagChipContent path={path} label={basenameOfPath(path)} theme={theme} />
</span>
);

return (
<Tooltip>
<TooltipTrigger render={chip} />
<TooltipPopup side="top" className="max-w-120 whitespace-normal leading-tight wrap-anywhere">
{props.path}
{path}
</TooltipPopup>
</Tooltip>
);
Expand Down Expand Up @@ -884,6 +918,7 @@ interface ComposerPromptEditorProps {
disabled: boolean;
placeholder: string;
className?: string;
onOpenMentionFile?: (path: string) => void;
onRemoveTerminalContext: (contextId: string) => void;
onChange: (
nextValue: string,
Expand Down Expand Up @@ -1105,7 +1140,7 @@ function ComposerInlineTokenSelectionNormalizePlugin() {

function ComposerInlineTokenBackspacePlugin() {
const [editor] = useLexicalComposerContext();
const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext);
const { onRemoveTerminalContext } = use(ComposerChipActionsContext);

useEffect(() => {
return editor.registerCommand(
Expand Down Expand Up @@ -1533,6 +1568,7 @@ function ComposerPromptEditorInner({
disabled,
placeholder,
className,
onOpenMentionFile,
onRemoveTerminalContext,
onChange,
onCommandKeyDown,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -1746,7 +1782,7 @@ function ComposerPromptEditorInner({
}, []);

return (
<ComposerTerminalContextActionsContext value={terminalContextActions}>
<ComposerChipActionsContext value={chipActions}>
<div className="composer-editor-surface relative">
<PlainTextPlugin
contentEditable={
Expand Down Expand Up @@ -1783,7 +1819,7 @@ function ComposerPromptEditorInner({
<ComposerChipSelectionPlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
</ComposerChipActionsContext>
);
}

Expand All @@ -1795,6 +1831,7 @@ export function ComposerPromptEditor({
disabled,
placeholder,
className,
onOpenMentionFile,
onRemoveTerminalContext,
onChange,
onCommandKeyDown,
Expand Down Expand Up @@ -1836,6 +1873,7 @@ export function ComposerPromptEditor({
onChange={onChange}
onPaste={onPaste}
editorRef={editorRef}
{...(onOpenMentionFile ? { onOpenMentionFile } : {})}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(className ? { className } : {})}
/>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand Down Expand Up @@ -643,6 +645,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
keybindings,
terminalOpen,
gitCwd,
onOpenMentionFile,
promptRef,
composerRef,
composerImagesRef,
Expand Down Expand Up @@ -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}
Expand Down
94 changes: 94 additions & 0 deletions apps/web/src/composerMentionFileTarget.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
64 changes: 64 additions & 0 deletions apps/web/src/composerMentionFileTarget.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading