;
+
+export const Default: Story = {};
+
+export const LineRange: Story = {
+ args: {
+ startLine: 10,
+ endLine: 50,
+ },
+};
+
+export const Selected: Story = {
+ args: {
+ selected: true,
+ },
+};
+
+export const InlineWithText: Story = {
+ render: (args) => (
+
+ Can you refactor to use the new API?
+
+ ),
+};
+
+export const LeftAlignedInline: Story = {
+ render: (args) => (
+
+ starts this message.
+
+ ),
+};
+
+export const AbuttingInlineText: Story = {
+ render: (args) => (
+
+ Before
+
+ after
+
+ ),
+};
+
+export const Editable: StoryObj = {
+ render: (args) => ,
+ args: {
+ fileName: "site/src/components/Button.tsx",
+ startLine: 42,
+ endLine: 42,
+ onOpen: fn(),
+ onRemove: fn(),
+ selected: true,
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement);
+ const trigger = canvas.getByRole("button", {
+ name: "Open site/src/components/Button.tsx:L42",
+ });
+ const removeButton = canvas.getByRole("button", {
+ name: "Remove reference",
+ });
+
+ await userEvent.click(trigger);
+ expect(args.onOpen).toHaveBeenCalledTimes(1);
+
+ await userEvent.click(removeButton);
+ expect(args.onRemove).toHaveBeenCalledTimes(1);
+ expect(args.onOpen).toHaveBeenCalledTimes(1);
+ },
+};
+
+/** Chip with a long filename that exceeds the max-width and truncates
+ * from the start, keeping the most distinctive part of the name visible. */
+export const LongFileNameTruncation: Story = {
+ args: {
+ fileName:
+ "site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx",
+ startLine: 274,
+ endLine: 289,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const chip = canvas.getByTitle(/UserCompactionThresholdSettings/);
+ // The chip should be constrained to its max-width and not overflow.
+ expect(chip.scrollWidth).toBeLessThanOrEqual(300);
+ },
+};
diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceChip.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceChip.tsx
new file mode 100644
index 0000000000..d674d5c091
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceChip.tsx
@@ -0,0 +1,178 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { XIcon } from "lucide-react";
+import type { CSSProperties, FC } from "react";
+import { FileIcon } from "#/components/FileIcon/FileIcon";
+import { cn } from "#/utils/cn";
+import { getFileReferenceDisplay } from "./fileReferenceDisplay";
+
+const fileReferenceChipVariants = cva(
+ "inline-flex min-h-5 max-w-[300px] select-none items-center gap-1 rounded-md border border-border-default bg-surface-primary py-0 pl-0.5 pr-1.5 align-middle font-sans text-[13px] font-normal leading-none text-inherit shadow-sm transition-colors",
+ {
+ variants: {
+ interactive: {
+ true: "cursor-pointer hover:border-border-secondary hover:bg-surface-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link",
+ false: "cursor-default",
+ },
+ selected: {
+ true: "border-content-link bg-content-link/10 text-content-primary ring-1 ring-content-link/40",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ interactive: false,
+ selected: false,
+ },
+ },
+);
+
+const fileReferenceTriggerVariants = cva(
+ "inline-flex min-w-0 items-center gap-1 border-0 bg-transparent p-0 font-sans text-[13px] font-normal leading-none text-inherit",
+ {
+ variants: {
+ interactive: {
+ true: "cursor-pointer focus-visible:outline-none",
+ false: "cursor-default",
+ },
+ },
+ defaultVariants: {
+ interactive: false,
+ },
+ },
+);
+
+const fileReferenceIconStyle: CSSProperties = {
+ fontSize: 16,
+ height: "1rem",
+ minWidth: "1rem",
+};
+
+type FileReferenceChipContentProps = {
+ fileName: string;
+ lineRange: string;
+};
+
+const FileReferenceChipContent: FC = ({
+ fileName,
+ lineRange,
+}) => {
+ return (
+ <>
+
+
+
+ {fileName}
+
+ ·
+ {lineRange}
+
+ >
+ );
+};
+
+type FileReferenceChipBaseProps = {
+ fileName: string;
+ startLine: number;
+ endLine: number;
+ className?: string;
+};
+
+type FileReferenceChipSelectedProps = Pick<
+ VariantProps,
+ "selected"
+>;
+
+type FileReferenceChipProps = FileReferenceChipBaseProps &
+ FileReferenceChipSelectedProps;
+
+export function FileReferenceChip({
+ fileName,
+ startLine,
+ endLine,
+ selected,
+ className,
+}: FileReferenceChipProps) {
+ const { shortFile, lineRange, title } = getFileReferenceDisplay({
+ fileName,
+ startLine,
+ endLine,
+ });
+
+ return (
+
+
+
+
+
+ );
+}
+
+export function EditableFileReferenceChip({
+ fileName,
+ startLine,
+ endLine,
+ selected,
+ onRemove,
+ onOpen,
+ className,
+}: FileReferenceChipBaseProps &
+ FileReferenceChipSelectedProps & {
+ onRemove: () => void;
+ onOpen: () => void;
+ }) {
+ const { shortFile, lineRange, title } = getFileReferenceDisplay({
+ fileName,
+ startLine,
+ endLine,
+ });
+
+ return (
+
+
+
+
+ );
+}
diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceNode.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceNode.stories.tsx
deleted file mode 100644
index fb3f96aeb7..0000000000
--- a/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceNode.stories.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, fn, within } from "storybook/test";
-import { FileReferenceChip } from "./FileReferenceNode";
-
-const meta: Meta = {
- title: "components/ChatMessageInput/FileReferenceChip",
- component: FileReferenceChip,
- args: {
- fileName: "site/src/components/Button.tsx",
- startLine: 42,
- endLine: 42,
- onRemove: fn(),
- onClick: fn(),
- },
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {};
-
-export const LineRange: Story = {
- args: {
- startLine: 10,
- endLine: 50,
- },
-};
-
-export const Selected: Story = {
- args: {
- isSelected: true,
- },
-};
-
-export const WithoutRemove: Story = {
- args: {
- onRemove: undefined,
- },
-};
-
-/** Chip with a long filename that exceeds the max-width and truncates
- * from the start, keeping the most distinctive part of the name visible. */
-export const LongFileNameTruncation: Story = {
- args: {
- fileName:
- "site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx",
- startLine: 274,
- endLine: 289,
- },
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- const chip = canvas.getByTitle(/UserCompactionThresholdSettings/);
- // The chip should be constrained to its max-width and not overflow.
- expect(chip.scrollWidth).toBeLessThanOrEqual(300);
- },
-};
diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceNode.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceNode.tsx
index 1abdcda3c8..b938ff16bd 100644
--- a/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceNode.tsx
+++ b/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceNode.tsx
@@ -8,10 +8,10 @@ import {
type SerializedLexicalNode,
type Spread,
} from "lexical";
-import { XIcon } from "lucide-react";
-import { type FC, memo, type ReactNode } from "react";
-import { FileIcon } from "#/components/FileIcon/FileIcon";
+import { type FC, type ReactNode, useSyncExternalStore } from "react";
import { cn } from "#/utils/cn";
+import { EditableFileReferenceChip } from "./FileReferenceChip";
+import { getFileReferenceSiblingSpacing } from "./fileReferenceDisplay";
type SerializedFileReferenceNode = Spread<
{
@@ -23,73 +23,6 @@ type SerializedFileReferenceNode = Spread<
SerializedLexicalNode
>;
-export function FileReferenceChip({
- fileName,
- startLine,
- endLine,
- isSelected,
- onRemove,
- onClick,
- className: extraClassName,
-}: {
- fileName: string;
- startLine: number;
- endLine: number;
- isSelected?: boolean;
- onRemove?: () => void;
- onClick?: () => void;
- className?: string;
-}) {
- const shortFile = fileName.split("/").pop() || fileName;
- const lineLabel =
- startLine === endLine ? `L${startLine}` : `L${startLine}–${endLine}`;
-
- return (
- {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onClick?.();
- }
- }}
- role="button"
- tabIndex={0}
- >
-
-
-
- {shortFile}
-
- :{lineLabel}
-
- {onRemove && (
-
- )}
-
- );
-}
-
export class FileReferenceNode extends DecoratorNode {
__fileName: string;
__startLine: number;
@@ -124,9 +57,8 @@ export class FileReferenceNode extends DecoratorNode {
this.__content = content;
}
- createDOM(config: EditorConfig): HTMLElement {
+ createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
- span.className = config.theme.inlineDecorator ?? "";
span.style.display = "inline";
span.style.userSelect = "none";
return span;
@@ -177,14 +109,40 @@ export class FileReferenceNode extends DecoratorNode {
}
}
+const SPACING_BEFORE = 1;
+const SPACING_AFTER = 2;
+
+const getFileReferenceSpacingSnapshot = (
+ editor: LexicalEditor,
+ nodeKey: NodeKey,
+) => {
+ const spacing = getFileReferenceSiblingSpacing(editor, nodeKey);
+ return (
+ (spacing.before ? SPACING_BEFORE : 0) | (spacing.after ? SPACING_AFTER : 0)
+ );
+};
+
+const useFileReferenceSpacing = (editor: LexicalEditor, nodeKey: NodeKey) => {
+ const spacingSnapshot = useSyncExternalStore(
+ (notify) => editor.registerUpdateListener(notify),
+ () => getFileReferenceSpacingSnapshot(editor, nodeKey),
+ );
+
+ return {
+ after: (spacingSnapshot & SPACING_AFTER) !== 0,
+ before: (spacingSnapshot & SPACING_BEFORE) !== 0,
+ };
+};
+
const FileReferenceChipWrapper: FC<{
editor: LexicalEditor;
nodeKey: NodeKey;
fileName: string;
startLine: number;
endLine: number;
-}> = memo(({ editor, nodeKey, fileName, startLine, endLine }) => {
+}> = ({ editor, nodeKey, fileName, startLine, endLine }) => {
const [isSelected] = useLexicalNodeSelection(nodeKey);
+ const spacing = useFileReferenceSpacing(editor, nodeKey);
const handleRemove = () => {
editor.update(() => {
@@ -204,17 +162,17 @@ const FileReferenceChipWrapper: FC<{
};
return (
-
);
-});
-FileReferenceChipWrapper.displayName = "FileReferenceChipWrapper";
+};
export function $createFileReferenceNode(
fileName: string,
diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/fileReferenceDisplay.test.ts b/site/src/pages/AgentsPage/components/ChatMessageInput/fileReferenceDisplay.test.ts
new file mode 100644
index 0000000000..7c61542e35
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatMessageInput/fileReferenceDisplay.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import {
+ getFileReferenceDisplay,
+ hasInlineContentAfter,
+ hasInlineContentBefore,
+} from "./fileReferenceDisplay";
+
+describe("getFileReferenceDisplay", () => {
+ it("returns the basename and line range title", () => {
+ expect(
+ getFileReferenceDisplay({
+ fileName: "site/src/pages/AgentsPage/components/Button.tsx",
+ startLine: 12,
+ endLine: 18,
+ }),
+ ).toEqual({
+ shortFile: "Button.tsx",
+ lineRange: "L12-L18",
+ title: "site/src/pages/AgentsPage/components/Button.tsx:L12-L18",
+ });
+ });
+
+ it("keeps the raw filename for paths without separators", () => {
+ expect(
+ getFileReferenceDisplay({
+ fileName: "main.go",
+ startLine: 7,
+ endLine: 7,
+ }),
+ ).toEqual({
+ shortFile: "main.go",
+ lineRange: "L7",
+ title: "main.go:L7",
+ });
+ });
+});
+
+describe("inline spacing helpers", () => {
+ const parts = [
+ { type: "text", text: "prefix" },
+ { type: "file-reference" },
+ { type: "text", text: "suffix" },
+ ] as const;
+
+ it("treats abutting text as inline content", () => {
+ expect(hasInlineContentBefore(parts, 1)).toBe(true);
+ expect(hasInlineContentAfter(parts, 1)).toBe(true);
+ });
+
+ it("ignores empty and whitespace-only text parts", () => {
+ const whitespaceParts = [
+ { type: "text", text: "" },
+ { type: "text", text: " " },
+ { type: "file-reference" },
+ { type: "text", text: "\n" },
+ ] as const;
+
+ expect(hasInlineContentBefore(whitespaceParts, 2)).toBe(false);
+ expect(hasInlineContentAfter(whitespaceParts, 2)).toBe(false);
+ });
+
+ it("treats adjacent file references as inline neighbors", () => {
+ const adjacentReferences = [
+ { type: "file-reference" },
+ { type: "file-reference" },
+ { type: "file-reference" },
+ ] as const;
+
+ expect(hasInlineContentBefore(adjacentReferences, 1)).toBe(true);
+ expect(hasInlineContentAfter(adjacentReferences, 1)).toBe(true);
+ });
+
+ it("uses the first non-empty text part on each side", () => {
+ const sparseParts = [
+ { type: "text", text: "" },
+ { type: "text", text: "leading" },
+ { type: "file-reference" },
+ { type: "text", text: "" },
+ { type: "text", text: "trailing" },
+ ] as const;
+
+ expect(hasInlineContentBefore(sparseParts, 2)).toBe(true);
+ expect(hasInlineContentAfter(sparseParts, 2)).toBe(true);
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/fileReferenceDisplay.ts b/site/src/pages/AgentsPage/components/ChatMessageInput/fileReferenceDisplay.ts
new file mode 100644
index 0000000000..5c92e358bd
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatMessageInput/fileReferenceDisplay.ts
@@ -0,0 +1,106 @@
+import type { LexicalEditor, LexicalNode, NodeKey } from "lexical";
+import { $getNodeByKey } from "lexical";
+import { getPathBasename } from "../../utils/path";
+
+export type InlinePart =
+ | { readonly type: "file-reference" }
+ | { readonly type: "text"; readonly text: string };
+
+const isFileReferenceNode = (node: LexicalNode) => {
+ return node.getType() === "file-reference";
+};
+
+export const getFileReferenceDisplay = ({
+ fileName,
+ startLine,
+ endLine,
+}: {
+ fileName: string;
+ startLine: number;
+ endLine: number;
+}) => {
+ const shortFile = getPathBasename(fileName);
+ const lineRange =
+ startLine === endLine ? `L${startLine}` : `L${startLine}-L${endLine}`;
+ const title = `${fileName}:${lineRange}`;
+
+ return { shortFile, lineRange, title };
+};
+
+export const hasInlineContentBefore = (
+ parts: readonly InlinePart[],
+ index: number,
+) => {
+ for (let i = index - 1; i >= 0; i--) {
+ const part = parts[i];
+ if (part.type === "file-reference") {
+ return true;
+ }
+ if (part.text.length > 0) {
+ return !/\s$/.test(part.text);
+ }
+ }
+ return false;
+};
+
+export const hasInlineContentAfter = (
+ parts: readonly InlinePart[],
+ index: number,
+) => {
+ for (let i = index + 1; i < parts.length; i++) {
+ const part = parts[i];
+ if (part.type === "file-reference") {
+ return true;
+ }
+ if (part.text.length > 0) {
+ return !/^\s/.test(part.text);
+ }
+ }
+ return false;
+};
+
+export const getFileReferenceSiblingSpacing = (
+ editor: LexicalEditor,
+ nodeKey: NodeKey,
+) => {
+ const parts: InlinePart[] = [];
+ let referenceIndex = -1;
+
+ editor.getEditorState().read(() => {
+ const node = $getNodeByKey(nodeKey);
+ if (!node) {
+ return;
+ }
+
+ let sibling = node.getPreviousSibling();
+ const previousParts: InlinePart[] = [];
+ while (sibling) {
+ if (isFileReferenceNode(sibling)) {
+ previousParts.unshift({ type: "file-reference" });
+ } else {
+ previousParts.unshift({ type: "text", text: sibling.getTextContent() });
+ }
+ sibling = sibling.getPreviousSibling();
+ }
+
+ parts.push(...previousParts);
+ referenceIndex = parts.length;
+ parts.push({ type: "file-reference" });
+
+ sibling = node.getNextSibling();
+ while (sibling) {
+ if (isFileReferenceNode(sibling)) {
+ parts.push({ type: "file-reference" });
+ } else {
+ parts.push({ type: "text", text: sibling.getTextContent() });
+ }
+ sibling = sibling.getNextSibling();
+ }
+ });
+
+ return {
+ after: referenceIndex >= 0 && hasInlineContentAfter(parts, referenceIndex),
+ before:
+ referenceIndex >= 0 && hasInlineContentBefore(parts, referenceIndex),
+ };
+};
diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx
index ec965f856a..3f36656751 100644
--- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx
+++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx
@@ -14,6 +14,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { isMobileViewport } from "#/utils/mobile";
+import { getPathBasename } from "../utils/path";
import { SvgRingProgress } from "./SvgRingProgress";
export interface AgentContextUsage {
@@ -64,12 +65,6 @@ const getIndicatorToneClassName = (percentUsed: number | null): string => {
return "text-content-secondary/60";
};
-/** Extract the trailing filename from an absolute path. */
-const basename = (path: string): string => {
- const slash = path.lastIndexOf("/");
- return slash >= 0 ? path.substring(slash + 1) : path;
-};
-
const RING_SIZE = 18;
const RING_STROKE = 2.5;
@@ -166,7 +161,7 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
>
- {basename(part.context_file_path)}
+ {getPathBasename(part.context_file_path)}
{part.context_file_truncated && (
diff --git a/site/src/pages/AgentsPage/utils/path.test.ts b/site/src/pages/AgentsPage/utils/path.test.ts
new file mode 100644
index 0000000000..a29b4a8f1b
--- /dev/null
+++ b/site/src/pages/AgentsPage/utils/path.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { getPathBasename } from "./path";
+
+describe("getPathBasename", () => {
+ it.each([
+ ["foo/bar.ts", "bar.ts"],
+ ["main.go", "main.go"],
+ ["", ""],
+ ["dir/", "dir/"],
+ ["/", "/"],
+ ])("returns the basename for %s", (path, expected) => {
+ expect(getPathBasename(path)).toBe(expected);
+ });
+});
diff --git a/site/src/pages/AgentsPage/utils/path.ts b/site/src/pages/AgentsPage/utils/path.ts
new file mode 100644
index 0000000000..51f44d2c9e
--- /dev/null
+++ b/site/src/pages/AgentsPage/utils/path.ts
@@ -0,0 +1,5 @@
+export const getPathBasename = (path: string): string => {
+ const slash = path.lastIndexOf("/");
+ const basename = slash >= 0 ? path.substring(slash + 1) : path;
+ return basename || path;
+};