mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
refactor(site/src/pages/AgentsPage/components): render project actions inside the chat input
This commit is contained in:
@@ -101,6 +101,7 @@ import type { AgentContextUsage } from "./ContextUsageIndicator";
|
||||
import { ContextUsageIndicator } from "./ContextUsageIndicator";
|
||||
import { ImageLightbox } from "./ImageLightbox";
|
||||
import { MCPServerIconStack } from "./MCPServerIconStack";
|
||||
import { ProjectSkillActions } from "./ProjectSkillActions";
|
||||
import { QueuedMessagesList } from "./QueuedMessagesList";
|
||||
import { TextPreviewDialog } from "./TextPreviewDialog";
|
||||
import { WorkspacePill } from "./WorkspacePill";
|
||||
@@ -187,8 +188,8 @@ type AgentChatInputProps = {
|
||||
chatProjectId?: string;
|
||||
isChatProjectPending?: boolean;
|
||||
isWorkspaceLoading?: boolean;
|
||||
// Shortcuts rendered above the composer, such as project actions.
|
||||
actionBar?: React.ReactNode;
|
||||
// Offers the chat project's action skills above the composer.
|
||||
showProjectActions?: boolean;
|
||||
// Queued user messages rendered above the textarea.
|
||||
queuedMessages?: readonly ChatQueuedMessage[];
|
||||
onDeleteQueuedMessage?: (id: number) => Promise<void> | void;
|
||||
@@ -534,7 +535,7 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
|
||||
chatProjectId,
|
||||
isChatProjectPending,
|
||||
isWorkspaceLoading,
|
||||
actionBar,
|
||||
showProjectActions = false,
|
||||
queuedMessages = [],
|
||||
onDeleteQueuedMessage,
|
||||
onPromoteQueuedMessage,
|
||||
@@ -1350,7 +1351,22 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
|
||||
isEditingHistoryMessage && "pt-1",
|
||||
)}
|
||||
>
|
||||
{actionBar}
|
||||
{showProjectActions &&
|
||||
experiments.includes("chat-projects") &&
|
||||
chatOrganizationId &&
|
||||
chatProjectId &&
|
||||
!isReadOnly &&
|
||||
!isReadOnlyView &&
|
||||
hasModelOptions &&
|
||||
!aiGatewayDisabled && (
|
||||
<ProjectSkillActions
|
||||
organizationId={chatOrganizationId}
|
||||
projectId={chatProjectId}
|
||||
onInvoke={(name) =>
|
||||
internalRef.current?.insertSkill("project", name)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{queuedMessages.length > 0 && (
|
||||
<QueuedMessagesList
|
||||
messages={queuedMessages}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { API } from "#/api/api";
|
||||
import { aiProvidersListKey } from "#/api/queries/aiProviders";
|
||||
import { chatProjectSkillsKey } from "#/api/queries/chatProjectSkills";
|
||||
import { chatProjectsKey } from "#/api/queries/chatProjects";
|
||||
import {
|
||||
mcpServerConfigsKey,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceBuildLogs,
|
||||
} from "#/testHelpers/entities";
|
||||
import { MockSkills } from "#/testHelpers/skills";
|
||||
import { withDashboardProvider, withToaster } from "#/testHelpers/storybook";
|
||||
import { persistedAttachmentsStorageKey } from "../hooks/useFileAttachments";
|
||||
import {
|
||||
@@ -2086,6 +2088,20 @@ export const WithOrganizationAndProjectPickers: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ProjectComposerWithActions: Story = {
|
||||
parameters: {
|
||||
experiments: ["chat-projects"],
|
||||
queries: [
|
||||
...defaultQueries,
|
||||
{
|
||||
key: chatProjectSkillsKey(MockChatProject.id),
|
||||
data: MockSkills.map((skill) => ({ ...skill, action: true })),
|
||||
},
|
||||
],
|
||||
},
|
||||
args: { project: MockChatProject },
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone story for the org-change confirmation dialog. Renders
|
||||
* the ConfirmDialog directly in its open state, following the same
|
||||
|
||||
@@ -60,7 +60,6 @@ import { CompactOrgSelector } from "./ChatElements/CompactOrgSelector";
|
||||
import { CompactProjectSelector } from "./ChatElements/CompactProjectSelector";
|
||||
import { ChatProjectDialog } from "./ChatsSidebar/dialogs/ChatProjectDialog";
|
||||
import { getModelSelectorHelp } from "./ModelSelectorHelp";
|
||||
import { ProjectSkillActions } from "./ProjectSkillActions";
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export const emptyInputStorageKey = "agents.empty-input";
|
||||
@@ -1085,21 +1084,7 @@ const AgentCreateFormContent: React.FC<AgentCreateFormProps> = ({
|
||||
mcpServers={mcpServers}
|
||||
chatOrganizationId={organizationId}
|
||||
chatProjectId={chatProjectId}
|
||||
actionBar={
|
||||
project &&
|
||||
experiments.includes("chat-projects") &&
|
||||
!isForbidden &&
|
||||
hasModelOptions &&
|
||||
!aiGatewayDisabled && (
|
||||
<ProjectSkillActions
|
||||
organizationId={project.organization_id}
|
||||
projectId={project.id}
|
||||
onInvoke={(name) =>
|
||||
chatInputRef.current?.insertSkill("project", name)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
showProjectActions={project !== undefined}
|
||||
selectedMCPServerIds={effectiveMCPServerIds}
|
||||
onMCPSelectionChange={(ids) => {
|
||||
setUserMCPServerIds(ids);
|
||||
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
import { useOnRenderProfiler } from "./ChatConversation/useOnRenderProfiler";
|
||||
import type { SkillMetadata } from "./ChatMessageInput/SkillsTriggerMenu";
|
||||
import { ChatMessageScroller } from "./ChatMessageScroller";
|
||||
import { ProjectSkillActions } from "./ProjectSkillActions";
|
||||
import { getWorkspaceOptionsWithLinkedWorkspace } from "./workspaceOptions";
|
||||
|
||||
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
|
||||
@@ -852,18 +851,7 @@ export const ChatPageInput: React.FC<ChatPageInputProps> = ({
|
||||
chatOrganizationId={organizationId}
|
||||
chatProjectId={chatProjectId}
|
||||
isChatProjectPending={!isChatProjectResolved}
|
||||
actionBar={
|
||||
chatProjectId &&
|
||||
!isReadOnly &&
|
||||
hasModelOptions &&
|
||||
!aiGatewayDisabled && (
|
||||
<ProjectSkillActions
|
||||
organizationId={organizationId}
|
||||
projectId={chatProjectId}
|
||||
onInvoke={(name) => inputRef.current?.insertSkill("project", name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
showProjectActions
|
||||
selectedWorkspaceId={selectedWorkspaceId}
|
||||
onWorkspaceChange={onWorkspaceChange}
|
||||
isWorkspaceLoading={workspacesQuery.isLoading || isWorkspaceLoading}
|
||||
|
||||
@@ -32,7 +32,7 @@ const meta: Meta<typeof ProjectSkillActions> = {
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-[480px]">
|
||||
<div className="w-120">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
@@ -42,6 +42,7 @@ const meta: Meta<typeof ProjectSkillActions> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProjectSkillActions>;
|
||||
|
||||
/** Hovers the action so the screenshot shows its description. */
|
||||
export const OneAction: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
@@ -51,6 +52,11 @@ export const OneAction: Story = {
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async () => {
|
||||
await userEvent.hover(
|
||||
await screen.findByRole("button", { name: "deploy" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ThreeActions: Story = {
|
||||
@@ -64,7 +70,7 @@ export const ThreeActions: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Actions past the third move to More, opened here. */
|
||||
/** Opens More so the screenshot shows the overflow actions. */
|
||||
export const FiveActionsMoreOpen: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
|
||||
@@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { API } from "#/api/api";
|
||||
import { chatProjectSkillsKey } from "#/api/queries/chatProjectSkills";
|
||||
import type { SkillMetadata } from "#/api/typesGenerated";
|
||||
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
|
||||
import { MockChatProject } from "#/testHelpers/entities";
|
||||
@@ -11,24 +10,29 @@ import { createTestQueryClient } from "#/testHelpers/renderHelpers";
|
||||
import { MockSkill } from "#/testHelpers/skills";
|
||||
import { ProjectSkillActions } from "./ProjectSkillActions";
|
||||
|
||||
const mockSkill = (
|
||||
name: string,
|
||||
overrides: Partial<SkillMetadata> = {},
|
||||
): SkillMetadata => ({
|
||||
...MockSkill,
|
||||
id: `skill-${name}`,
|
||||
name,
|
||||
description: `Runs ${name}.`,
|
||||
action: true,
|
||||
...overrides,
|
||||
});
|
||||
// "audit" and "check" sort first, so either would take an inline slot if
|
||||
// non-action or disabled skills were offered.
|
||||
const mockSkills: SkillMetadata[] = [
|
||||
{ ...MockSkill, id: "skill-triage", name: "triage", action: true },
|
||||
{ ...MockSkill, id: "skill-audit", name: "audit", action: false },
|
||||
{ ...MockSkill, id: "skill-review", name: "review", action: true },
|
||||
{
|
||||
...MockSkill,
|
||||
id: "skill-check",
|
||||
name: "check",
|
||||
action: true,
|
||||
enabled: false,
|
||||
},
|
||||
{ ...MockSkill, id: "skill-release", name: "release", action: true },
|
||||
{ ...MockSkill, id: "skill-lint", name: "lint", action: true },
|
||||
{ ...MockSkill, id: "skill-deploy", name: "deploy", action: true },
|
||||
];
|
||||
|
||||
const renderActions = (skills: SkillMetadata[]) => {
|
||||
vi.spyOn(API.experimental, "getChatProjectSkills").mockResolvedValue(skills);
|
||||
const queryClient = createTestQueryClient();
|
||||
const onInvoke = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<TooltipProvider>
|
||||
<ProjectSkillActions
|
||||
organizationId={MockChatProject.organization_id}
|
||||
@@ -38,7 +42,7 @@ const renderActions = (skills: SkillMetadata[]) => {
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { user: userEvent.setup(), queryClient, onInvoke };
|
||||
return { user: userEvent.setup(), onInvoke };
|
||||
};
|
||||
|
||||
describe("ProjectSkillActions", () => {
|
||||
@@ -47,49 +51,25 @@ describe("ProjectSkillActions", () => {
|
||||
});
|
||||
|
||||
it("offers the first three actions by name and the rest under More", async () => {
|
||||
// "audit" and "check" sort first, so either would take an inline
|
||||
// slot if non-action or disabled skills were offered.
|
||||
const { user, onInvoke } = renderActions([
|
||||
mockSkill("triage"),
|
||||
mockSkill("audit", { action: false }),
|
||||
mockSkill("review"),
|
||||
mockSkill("check", { enabled: false }),
|
||||
mockSkill("release"),
|
||||
mockSkill("lint"),
|
||||
mockSkill("deploy"),
|
||||
]);
|
||||
const { user, onInvoke } = renderActions(mockSkills);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "deploy" }));
|
||||
await user.click(screen.getByRole("button", { name: "lint" }));
|
||||
await user.click(screen.getByRole("button", { name: "release" }));
|
||||
for (const name of ["review", "triage"]) {
|
||||
await user.click(screen.getByRole("button", { name: "More" }));
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: new RegExp(`^${name}`) }),
|
||||
);
|
||||
await waitFor(() => expect(onInvoke).toHaveBeenLastCalledWith(name));
|
||||
}
|
||||
|
||||
expect(onInvoke.mock.calls).toEqual([
|
||||
["deploy"],
|
||||
["lint"],
|
||||
["release"],
|
||||
["review"],
|
||||
["triage"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers nothing when no enabled skill is an action", async () => {
|
||||
const { queryClient } = renderActions([
|
||||
mockSkill("audit", { action: false }),
|
||||
mockSkill("check", { enabled: false }),
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "More" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||
await waitFor(() => expect(onInvoke).toHaveBeenLastCalledWith("review"));
|
||||
await user.click(screen.getByRole("button", { name: "More" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "triage" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryData(chatProjectSkillsKey(MockChatProject.id)),
|
||||
).toHaveLength(2),
|
||||
expect(onInvoke.mock.calls).toEqual([
|
||||
["deploy"],
|
||||
["lint"],
|
||||
["release"],
|
||||
["review"],
|
||||
["triage"],
|
||||
]),
|
||||
);
|
||||
expect(screen.queryAllByRole("button")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,8 +113,13 @@ const ProjectSkillActionButton: React.FC<ProjectSkillActionButtonProps> = ({
|
||||
onInvoke,
|
||||
}) => {
|
||||
const button = (
|
||||
<Button size="sm" variant="outline" onClick={() => onInvoke(skill.name)}>
|
||||
{skill.name}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="max-w-full"
|
||||
onClick={() => onInvoke(skill.name)}
|
||||
>
|
||||
<span className="truncate">{skill.name}</span>
|
||||
</Button>
|
||||
);
|
||||
if (!skill.description) {
|
||||
|
||||
Reference in New Issue
Block a user