Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/src/pages/AgentsPage/components/AgentChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
workspaceSkills={workspaceSkills}
autoFocus
slashCommands={slashCommands}
skillsMenuAnchor={composerElement}
/>
{/* Warn about invisible Unicode in the message text.
* Unlike the admin/user prompt textareas (which strip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const EmptySkills: Story = {
},
};

export const FilteredEmptyKeepsMenuOpen: Story = {
export const FilteredEmptyEnterClosesAndSubmits: Story = {
args: {
onEnter: fn(),
},
Expand All @@ -112,11 +112,60 @@ export const FilteredEmptyKeepsMenuOpen: Story = {
await findVisibleText("No personal skills match that query."),
).toBeDefined();
await userEvent.keyboard("{Enter}");
expect(args.onEnter).not.toHaveBeenCalled();
expect(args.onEnter).toHaveBeenCalledTimes(1);
await expectNoVisibleText("No personal skills match that query.");
expect(editor.textContent).toBe("/zzzz");
},
};

// A trailing absolute path is not a skill query; Enter must close the
// no-match menu and submit the prompt in the same keypress (CODAGT-956).
export const TrailingPathEnterSubmits: Story = {
args: {
slashCommands: [COMPACT_SLASH_COMMAND],
onEnter: fn(),
},
play: async ({ canvasElement, args }) => {
const editor = await typeInEditor(canvasElement, "check /var/log/syslog");
expect(
await findVisibleText("No personal skills match that query."),
).toBeDefined();
await userEvent.keyboard("{Enter}");
expect(args.onEnter).toHaveBeenCalledTimes(1);
await expectNoVisibleText("No personal skills match that query.");
expect(editor.textContent).toBe("check /var/log/syslog");
},
};

// The menu anchors where "/" was typed and must not follow the caret
// as the query grows.
export const MenuStaysAnchoredWhileTyping: Story = {
play: async ({ canvasElement }) => {
await typeInEditor(canvasElement, "/re");
const item = await findVisibleText("/reviewer");
const wrapper = item.closest<HTMLElement>(
"[data-radix-popper-content-wrapper]",
);
expect(wrapper).not.toBeNull();
if (!wrapper) return;
const before = wrapper.getBoundingClientRect();
await userEvent.keyboard("view");
await findVisibleText("/reviewer");
// Headless runs do not fire floating-ui's layout-shift tracking,
// so force a reposition through its resize listener, then hold
// the position stable long enough to catch a moved anchor.
dispatchEvent(new Event("resize"));
for (let frame = 0; frame < 30; frame++) {
const rect = wrapper.getBoundingClientRect();
expect({ top: rect.top, left: rect.left }).toEqual({
top: before.top,
left: before.left,
});
Comment thread
ibetitsmike marked this conversation as resolved.
await new Promise((resolve) => requestAnimationFrame(resolve));
}
},
};

export const FiltersByQuery: Story = {
play: async ({ canvasElement }) => {
await typeInEditor(canvasElement, "/rev");
Expand Down Expand Up @@ -270,6 +319,25 @@ export const EmptyPersonalKeepsMenuOpenWhileWorkspaceSkillsUnknown: Story = {
},
};

// While a skill source is still loading, a zero-match Enter must keep
// the menu open instead of submitting a token that may become a skill
// trigger once results arrive.
export const EnterWhileSkillsLoadingDoesNotSubmit: Story = {
args: {
personalSkillsOverride: [],
hasWorkspace: true,
onEnter: fn(),
},
play: async ({ canvasElement, args }) => {
const editor = await typeInEditor(canvasElement, "/rev");
expect(await findVisibleText("Loading workspace skills...")).toBeDefined();
await userEvent.keyboard("{Enter}");
expect(args.onEnter).not.toHaveBeenCalled();
expect(await findVisibleText("Loading workspace skills...")).toBeDefined();
expect(editor.textContent).toBe("/rev");
},
};

export const QualifiedPersonalQueryMatchesBareTrigger: Story = {
args: {
hasWorkspace: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,11 @@ interface ChatMessageInputProps
* composer intercepts it at submit time.
*/
slashCommands?: readonly ChatSlashCommand[];
/**
* Composer box element the skills menu is pinned above and sized
* to match. Falls back to this component's own container.
*/
skillsMenuAnchor?: HTMLElement | null;
"aria-label"?: string;
}

Expand Down Expand Up @@ -572,10 +577,7 @@ const isSameSkillsTrigger = (
return (
a.nodeKey === b.nodeKey &&
a.slashOffset === b.slashOffset &&
a.query === b.query &&
a.anchorRect?.top === b.anchorRect?.top &&
a.anchorRect?.left === b.anchorRect?.left &&
a.anchorRect?.height === b.anchorRect?.height
a.query === b.query
);
};

Expand All @@ -597,6 +599,7 @@ const ChatMessageInput = ({
personalSkillsOverride,
workspaceSkills,
slashCommands,
skillsMenuAnchor,
"aria-label": ariaLabel,
ref,
...props
Expand All @@ -622,6 +625,8 @@ const ChatMessageInput = ({
const pendingReplacementRef = useRef<string | null>(null);
const [skillsTrigger, setSkillsTrigger] =
useState<ActiveSkillsTrigger | null>(null);
const [containerElement, setContainerElement] =
useState<HTMLDivElement | null>(null);
const suppressedSkillsTriggerRef = useRef<SkillsTriggerLocation | null>(null);
const [skillsMenuSelectedIndex, setSkillsMenuSelectedIndex] = useState(0);
const hasSkillsTrigger = Boolean(skillsTrigger);
Expand Down Expand Up @@ -935,6 +940,7 @@ const ChatMessageInput = ({
return (
<LexicalComposer initialConfig={initialConfig} key={remountKey}>
<div
ref={setContainerElement}
className={cn(
"grid w-full rounded-md bg-transparent text-base placeholder:text-content-secondary focus-visible:outline-none whitespace-pre-wrap break-words [&>*]:col-start-1 [&>*]:row-start-1",
disabled && "cursor-not-allowed opacity-50",
Expand Down Expand Up @@ -980,6 +986,12 @@ const ChatMessageInput = ({
<SkillsTriggerPlugin
open={skillsMenuOpen}
skills={allFilteredSkills}
skillsLoading={
(personalSkillsQueryEnabled &&
skillsQuery.isFetching &&
skillsQuery.data === undefined) ||
Comment thread
ibetitsmike marked this conversation as resolved.
!workspaceSkillsKnown
}
selectedIndex={selectedSkillIndex}
onSelectedIndexChange={setSkillsMenuSelectedIndex}
onTriggerChange={handleSkillsTriggerChange}
Expand All @@ -989,7 +1001,7 @@ const ChatMessageInput = ({
{autoFocus && <AutoFocusPlugin />}
<SkillsTriggerMenu
open={skillsMenuOpen}
anchorRect={skillsTrigger?.anchorRect ?? null}
anchor={skillsMenuAnchor ?? containerElement}
query={skillsSearchQuery}
commands={commandMenuItems}
personalSkills={personalSkillItems}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type ComponentProps, useState } from "react";
import { expect, fn, userEvent } from "storybook/test";
import { expect, fn, userEvent, waitFor } from "storybook/test";
import { filterSkillsByQuery } from "../../utils/personalSkills";
import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands";
import {
Expand Down Expand Up @@ -35,12 +35,29 @@ const mockWorkspaceSkillItems = mockWorkspaceSkills.map((skill) =>
createSkillMenuItem("workspace", skill),
);

// Provides the composer-box element the menu anchors to, since the
// menu is pinned above its anchor at the anchor's width.
const MenuStoryHarness = (args: ComponentProps<typeof SkillsTriggerMenu>) => {
const [anchor, setAnchor] = useState<HTMLDivElement | null>(null);
return (
<>
<div
ref={setAnchor}
className="mt-64 h-16 w-64 rounded-md border border-border border-solid p-2 text-content-secondary text-sm"
>
Mock composer
</div>
<SkillsTriggerMenu {...args} anchor={anchor} />
</>
);
};

const meta: Meta<typeof SkillsTriggerMenu> = {
title: "components/ChatMessageInput/SkillsTriggerMenu",
component: SkillsTriggerMenu,
args: {
open: true,
anchorRect: { top: 120, left: 80, height: 20 },
anchor: null,
query: "",
personalSkills: mockPersonalSkillItems,
workspaceSkills: [],
Expand All @@ -50,12 +67,10 @@ const meta: Meta<typeof SkillsTriggerMenu> = {
onSelect: fn(),
onClose: fn(),
},
render: (args) => <MenuStoryHarness {...args} />,
decorators: [
(Story) => (
<div className="h-80 p-6">
<p className="text-content-secondary text-sm">
The menu is anchored to a mock caret position.
</p>
<div className="h-96 p-6">
<Story />
</div>
),
Expand Down Expand Up @@ -161,13 +176,16 @@ const SelectionScrollHarness = (
args: ComponentProps<typeof SkillsTriggerMenu>,
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [anchor, setAnchor] = useState<HTMLDivElement | null>(null);
return (
<>
<button type="button" onClick={() => setSelectedIndex(29)}>
Highlight last skill
</button>
<div ref={setAnchor} className="mt-96 h-16 w-96" />
<SkillsTriggerMenu
{...args}
anchor={anchor}
selectedIndex={selectedIndex}
onSelectedIndexChange={setSelectedIndex}
/>
Expand Down Expand Up @@ -240,3 +258,26 @@ export const SelectsCommandByClick: Story = {
expect(args.onSelect).toHaveBeenCalledWith(compactCommandItem);
},
};

// The menu opens above its anchor at the anchor's exact width,
// matching the mobile pinned-above-composer placement (CODAGT-956).
export const OpensAboveAnchorAtAnchorWidth: Story = {
play: async () => {
const item = await findVisibleText("/reviewer");
const content = item.closest("[data-side]");
expect(content).not.toBeNull();
expect(content).toHaveAttribute("data-side", "top");
const anchorBox = (await findVisibleText("Mock composer")).closest("div");
expect(anchorBox).not.toBeNull();
if (!anchorBox || !(content instanceof HTMLElement)) return;
// The entrance animation scales the content from 95%, so wait
// for the settled geometry.
await waitFor(() => {
const anchorRect = anchorBox.getBoundingClientRect();
const contentRect = content.getBoundingClientRect();
expect(contentRect.width).toBeCloseTo(anchorRect.width, 0);
expect(contentRect.left).toBeCloseTo(anchorRect.left, 0);
expect(contentRect.bottom).toBeLessThanOrEqual(anchorRect.top);
Comment thread
ibetitsmike marked this conversation as resolved.
});
},
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef } from "react";
import {
Command,
CommandEmpty,
Expand All @@ -13,15 +13,6 @@ import {
} from "#/components/Popover/Popover";
import { cn } from "#/utils/cn";

// Prevent zero-height anchors when the browser returns a degenerate caret rect.
const MIN_ANCHOR_HEIGHT_PX = 16;

export type CaretAnchorRect = {
top: number;
left: number;
height: number;
};

type SkillSource = "personal" | "workspace";

export type SkillMetadata = {
Expand Down Expand Up @@ -67,7 +58,8 @@ export const createSkillMenuItem = (

type SkillsTriggerMenuProps = {
open: boolean;
anchorRect: CaretAnchorRect | null;
// The composer box the menu is pinned above and sized to match.
anchor: HTMLElement | null;
query: string;
commands?: readonly SkillMenuItem[];
personalSkills: readonly SkillMenuItem[];
Expand Down Expand Up @@ -160,7 +152,7 @@ const SkillCommandItem = ({

export const SkillsTriggerMenu = ({
open,
anchorRect,
anchor,
query,
commands = [],
personalSkills,
Expand All @@ -186,15 +178,7 @@ export const SkillsTriggerMenu = ({
? "Loading workspace skills..."
: undefined,
].filter((item) => item !== undefined);
const shouldRender = open && anchorRect;
// Radix keeps closing content mounted through its exit animation.
// Unmounting the anchor then would reposition the closing menu to
// the viewport origin, so keep it at the last known caret rect.
const [lastAnchorRect, setLastAnchorRect] = useState(anchorRect);
if (anchorRect && anchorRect !== lastAnchorRect) {
setLastAnchorRect(anchorRect);
}
const renderedAnchorRect = anchorRect ?? lastAnchorRect;
const shouldRender = open && anchor !== null;
const shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0;
const selectedValue = selectedIndex >= 0 ? String(selectedIndex) : "";

Expand Down Expand Up @@ -239,25 +223,12 @@ export const SkillsTriggerMenu = ({
}
}}
>
{renderedAnchorRect && (
<PopoverAnchor asChild>
<span
aria-hidden="true"
style={{
position: "fixed",
top: renderedAnchorRect.top,
left: renderedAnchorRect.left,
width: 1,
height: Math.max(renderedAnchorRect.height, MIN_ANCHOR_HEIGHT_PX),
pointerEvents: "none",
}}
/>
</PopoverAnchor>
)}
{anchor && <PopoverAnchor virtualRef={{ current: anchor }} />}
<PopoverContent
align="start"
side="bottom"
className="w-80 overflow-hidden p-1 mobile-full-width-dropdown mobile-full-width-dropdown-above-composer"
side="top"
sideOffset={8}
className="w-[var(--radix-popper-anchor-width)] overflow-hidden p-1 mobile-full-width-dropdown mobile-full-width-dropdown-above-composer"
onMouseDown={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
Expand Down
Loading
Loading