diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx index 924bee9b0c..7443d88b41 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx @@ -1,7 +1,12 @@ import { type FC, Fragment } from "react"; import { cn } from "#/utils/cn"; import { Message, MessageContent } from "../ChatElements"; -import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode"; +import { FileReferenceChip } from "../ChatMessageInput/FileReferenceChip"; +import { + hasInlineContentAfter, + hasInlineContentBefore, + type InlinePart, +} from "../ChatMessageInput/fileReferenceDisplay"; import { AttachmentBlock, type PreviewTextAttachment, @@ -11,7 +16,22 @@ import type { UserInlineRenderBlock, } from "./messageHelpers"; -const renderUserInlineBlock = (block: UserInlineRenderBlock, index: number) => { +const getInlineParts = ( + blocks: readonly UserInlineRenderBlock[], +): InlinePart[] => { + return blocks.map((block) => { + if (block.type === "file-reference") { + return { type: "file-reference" }; + } + return { type: "text", text: block.text }; + }); +}; + +const renderUserInlineBlock = ( + inlineParts: readonly InlinePart[], + block: UserInlineRenderBlock, + index: number, +) => { if (block.type === "response") { return {block.text}; } @@ -22,11 +42,21 @@ const renderUserInlineBlock = (block: UserInlineRenderBlock, index: number) => { fileName={block.file_name} startLine={block.start_line} endLine={block.end_line} - className="mx-1" + className={cn( + hasInlineContentBefore(inlineParts, index) && "ml-1", + hasInlineContentAfter(inlineParts, index) && "mr-1", + )} /> ); }; +const renderUserInlineContent = (blocks: readonly UserInlineRenderBlock[]) => { + const inlineParts = getInlineParts(blocks); + return blocks.map((block, index) => + renderUserInlineBlock(inlineParts, block, index), + ); +}; + export const UserMessageContent: FC<{ displayState: MessageDisplayState; markdown: string; @@ -61,9 +91,7 @@ export const UserMessageContent: FC<{ {displayState.hasUserMessageBody && ( {displayState.userInlineContent.length > 0 - ? displayState.userInlineContent.map((block, index) => - renderUserInlineBlock(block, index), - ) + ? renderUserInlineContent(displayState.userInlineContent) : markdown || ""} )} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx index 0df55ff068..a2b40c0a80 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx @@ -4,6 +4,7 @@ import { FileDiff } from "@pierre/diffs/react"; import type React from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { getPathBasename } from "../../../utils/path"; import { DiffFileHeader } from "./DiffFileHeader"; import { type AgentDisplayState, @@ -41,14 +42,14 @@ export const EditFilesTool: React.FC<{ let label: string; if (isRunning) { if (files.length === 1) { - label = `Editing ${files[0].path.split("/").pop() || files[0].path}…`; + label = `Editing ${getPathBasename(files[0].path)}…`; } else if (files.length > 1) { label = `Editing ${files.length} files…`; } else { label = "Editing files…"; } } else if (files.length === 1) { - const filename = files[0].path.split("/").pop() || files[0].path; + const filename = getPathBasename(files[0].path); label = `Edited ${filename}`; } else if (files.length > 1) { label = `Edited ${files.length} files`; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx index 2f9c0270e2..456e4022cf 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, spyOn, userEvent, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { API } from "#/api/api"; +import { getPathBasename } from "../../../utils/path"; import { Tool } from "./Tool"; const samplePlan = [ @@ -37,7 +38,7 @@ const samplePlan = [ const defaultPlanPath = "/home/coder/.coder/plans/PLAN-a1b2c3d4-e5f6-7890-abcd-ef1234567890.md"; -const defaultPlanFilename = defaultPlanPath.split("/").pop() ?? "PLAN.md"; +const defaultPlanFilename = getPathBasename(defaultPlanPath) || "PLAN.md"; const meta: Meta = { title: "pages/AgentsPage/ChatElements/tools/ProposePlan", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx index e1ee74db1a..c08da0db5c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx @@ -9,6 +9,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { getPathBasename } from "../../../utils/path"; import { Response } from "../Response"; import { TranscriptRow } from "../TranscriptRow"; import { ToolCall } from "./ToolCall"; @@ -55,7 +56,7 @@ export const ProposePlanTool: React.FC<{ ? (inlineContent ?? "") : (fileQuery.data ?? ""); const isRunning = status === "running"; - const filename = (path || "PLAN.md").split("/").pop() || "PLAN.md"; + const filename = getPathBasename(path || "PLAN.md") || "PLAN.md"; const effectiveError = isError || Boolean(fetchError); const effectiveErrorMessage = errorMessage || fetchError; const hasDisplayContent = displayContent.trim().length > 0; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx index 103666e285..e9462fa793 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx @@ -2,6 +2,7 @@ import { useTheme } from "@emotion/react"; import { File as FileViewer } from "@pierre/diffs/react"; import type React from "react"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { getPathBasename } from "../../../utils/path"; import { asRecord, asString } from "../runtimeTypeUtils"; import { ToolCall } from "./ToolCall"; import { @@ -81,7 +82,7 @@ export const ReadFileTool: React.FC<{ }) => { const hasContent = content.length > 0 || isError; const isRunning = status === "running"; - const filename = path.split("/").pop() || path; + const filename = getPathBasename(path); const label = isRunning ? `Reading ${filename}…` : `Read ${filename}`; return ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx index 68df2fcf76..4e0ff7d54c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { getPathBasename } from "../../../utils/path"; import { getProvidedSubagentTitle, getSubagentDescriptor, @@ -187,7 +188,7 @@ export const ToolLabel: React.FC<{ const attachedName = (parsedResult ? asString(parsedResult.name) : "") || (parsed ? asString(parsed.name) : "") || - (parsed ? asString(parsed.path).split("/").pop() : "") || + (parsed ? getPathBasename(asString(parsed.path)) : "") || "file"; return ( {`Attached ${attachedName}`} @@ -197,7 +198,7 @@ export const ToolLabel: React.FC<{ return Screenshot; case "propose_plan": { const path = parsed ? asString(parsed.path) || "PLAN.md" : "PLAN.md"; - const filename = path.split("/").pop() || "PLAN.md"; + const filename = getPathBasename(path) || "PLAN.md"; return {filename}; } case "advisor": diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx index 54fb1a8381..0a9d41480c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx @@ -4,6 +4,7 @@ import { FileDiff } from "@pierre/diffs/react"; import type React from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { getPathBasename } from "../../../utils/path"; import { DiffFileHeader } from "./DiffFileHeader"; import { type AgentDisplayState, @@ -37,7 +38,7 @@ export const WriteFileTool: React.FC<{ WRITE_FILE_AUTO_DISPLAY_STATE, ); - const filename = path.split("/").pop() || path; + const filename = getPathBasename(path); const label = isRunning ? `Writing ${filename}…` : `Wrote ${filename}`; return ( diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 431a537d5f..a4dfc09bfa 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -576,7 +576,6 @@ const ChatMessageInput = ({ namespace: "ChatMessageInput", theme: { paragraph: "m-0", - inlineDecorator: "mx-1", }, onError: (error: Error) => console.error("Lexical error:", error), nodes: [FileReferenceNode], diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceChip.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceChip.stories.tsx new file mode 100644 index 0000000000..5ed3ec8f71 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/FileReferenceChip.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { + EditableFileReferenceChip, + FileReferenceChip, +} from "./FileReferenceChip"; + +const meta: Meta = { + title: "components/ChatMessageInput/FileReferenceChip", + component: FileReferenceChip, + args: { + fileName: "site/src/components/Button.tsx", + startLine: 42, + endLine: 42, + }, + 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: { + 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; +};