fix(site/src/pages/AgentsPage): separate interactive and display file reference chips (#26094)

This commit is contained in:
Danielle Maywood
2026-06-15 10:01:48 +01:00
committed by GitHub
parent ba64724f8a
commit 2c754630f6
17 changed files with 586 additions and 163 deletions
@@ -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 <Fragment key={index}>{block.text}</Fragment>;
}
@@ -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 && (
<span className="min-w-0 flex-1">
{displayState.userInlineContent.length > 0
? displayState.userInlineContent.map((block, index) =>
renderUserInlineBlock(block, index),
)
? renderUserInlineContent(displayState.userInlineContent)
: markdown || ""}
</span>
)}
@@ -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`;
@@ -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<typeof Tool> = {
title: "pages/AgentsPage/ChatElements/tools/ProposePlan",
@@ -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;
@@ -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 (
@@ -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 (
<span className="truncate text-[13px]">{`Attached ${attachedName}`}</span>
@@ -197,7 +198,7 @@ export const ToolLabel: React.FC<{
return <span className="truncate text-[13px]">Screenshot</span>;
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 <span className="truncate text-[13px]">{filename}</span>;
}
case "advisor":
@@ -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 (
@@ -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],
@@ -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<typeof FileReferenceChip> = {
title: "components/ChatMessageInput/FileReferenceChip",
component: FileReferenceChip,
args: {
fileName: "site/src/components/Button.tsx",
startLine: 42,
endLine: 42,
},
decorators: [
(Story) => (
<div style={{ padding: 24 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof FileReferenceChip>;
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) => (
<p className="m-0 font-sans text-sm leading-6 text-content-primary">
Can you refactor <FileReferenceChip {...args} /> to use the new API?
</p>
),
};
export const LeftAlignedInline: Story = {
render: (args) => (
<p className="m-0 font-sans text-sm leading-6 text-content-primary">
<FileReferenceChip {...args} /> starts this message.
</p>
),
};
export const AbuttingInlineText: Story = {
render: (args) => (
<p className="m-0 font-sans text-sm leading-6 text-content-primary">
<span>Before</span>
<FileReferenceChip {...args} className="ml-1 mr-1" />
<span>after</span>
</p>
),
};
export const Editable: StoryObj<typeof EditableFileReferenceChip> = {
render: (args) => <EditableFileReferenceChip {...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);
},
};
@@ -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<FileReferenceChipContentProps> = ({
fileName,
lineRange,
}) => {
return (
<>
<FileIcon
fileName={fileName}
className="shrink-0"
style={fileReferenceIconStyle}
/>
<span
data-slot="file-reference-chip-label"
className="inline-flex min-w-0 items-center gap-0.5"
>
<span dir="rtl" className="min-w-0 truncate">
{fileName}
</span>
<span className="shrink-0">·</span>
<span className="shrink-0 tabular-nums">{lineRange}</span>
</span>
</>
);
};
type FileReferenceChipBaseProps = {
fileName: string;
startLine: number;
endLine: number;
className?: string;
};
type FileReferenceChipSelectedProps = Pick<
VariantProps<typeof fileReferenceChipVariants>,
"selected"
>;
type FileReferenceChipProps = FileReferenceChipBaseProps &
FileReferenceChipSelectedProps;
export function FileReferenceChip({
fileName,
startLine,
endLine,
selected,
className,
}: FileReferenceChipProps) {
const { shortFile, lineRange, title } = getFileReferenceDisplay({
fileName,
startLine,
endLine,
});
return (
<span
data-slot="file-reference-chip"
className={cn(fileReferenceChipVariants({ selected }), className)}
title={title}
>
<span
data-slot="file-reference-chip-trigger"
className={fileReferenceTriggerVariants()}
>
<FileReferenceChipContent fileName={shortFile} lineRange={lineRange} />
</span>
</span>
);
}
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 (
<span
data-slot="file-reference-chip"
className={cn(
fileReferenceChipVariants({ interactive: true, selected }),
"border-border-secondary bg-surface-tertiary text-content-primary hover:bg-surface-quaternary",
className,
)}
contentEditable={false}
title={title}
>
<button
data-slot="file-reference-chip-trigger"
type="button"
className={fileReferenceTriggerVariants({ interactive: true })}
onClick={onOpen}
aria-label={`Open ${title}`}
>
<FileReferenceChipContent fileName={shortFile} lineRange={lineRange} />
</button>
<button
data-slot="file-reference-chip-remove"
type="button"
className="ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors hover:bg-surface-quaternary hover:text-content-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label="Remove reference"
tabIndex={-1}
>
<XIcon className="size-2.5" />
</button>
</span>
);
}
@@ -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<typeof FileReferenceChip> = {
title: "components/ChatMessageInput/FileReferenceChip",
component: FileReferenceChip,
args: {
fileName: "site/src/components/Button.tsx",
startLine: 42,
endLine: 42,
onRemove: fn(),
onClick: fn(),
},
decorators: [
(Story) => (
<div style={{ padding: 24 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof FileReferenceChip>;
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);
},
};
@@ -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 (
<span
className={cn(
"inline-flex h-6 max-w-[300px] cursor-pointer select-none items-center gap-1.5 rounded-md border border-border-default bg-surface-primary px-1.5 align-middle text-xs text-content-primary shadow-sm transition-colors",
isSelected &&
"border-content-link bg-content-link/10 ring-1 ring-content-link/40",
extraClassName,
)}
contentEditable={false}
title={`${fileName}:${lineLabel}`}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
role="button"
tabIndex={0}
>
<FileIcon fileName={shortFile} className="shrink-0" />
<span className="inline-flex min-w-0 text-content-secondary">
<span dir="rtl" className="min-w-0 truncate">
{shortFile}
</span>
<span className="shrink-0 text-content-link">:{lineLabel}</span>
</span>
{onRemove && (
<button
type="button"
className="ml-auto inline-flex size-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors hover:text-content-primary cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label="Remove reference"
tabIndex={-1}
>
<XIcon className="size-2" />
</button>
)}
</span>
);
}
export class FileReferenceNode extends DecoratorNode<ReactNode> {
__fileName: string;
__startLine: number;
@@ -124,9 +57,8 @@ export class FileReferenceNode extends DecoratorNode<ReactNode> {
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<ReactNode> {
}
}
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 (
<FileReferenceChip
<EditableFileReferenceChip
fileName={fileName}
startLine={startLine}
endLine={endLine}
isSelected={isSelected}
selected={isSelected}
onRemove={handleRemove}
onClick={handleClick}
onOpen={handleClick}
className={cn(spacing.before && "ml-1", spacing.after && "mr-1")}
/>
);
});
FileReferenceChipWrapper.displayName = "FileReferenceChipWrapper";
};
export function $createFileReferenceNode(
fileName: string,
@@ -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);
});
});
@@ -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),
};
};
@@ -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 }> = ({
>
<FileIcon className="size-3 shrink-0" />
<span className="truncate" title={part.context_file_path}>
{basename(part.context_file_path)}
{getPathBasename(part.context_file_path)}
</span>
{part.context_file_truncated && (
<span className="shrink-0 text-content-warning">
@@ -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);
});
});
+5
View File
@@ -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;
};