mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site/src/pages/AgentsPage): separate interactive and display file reference chips (#26094)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user