refactor(site/src/pages/AgentsPage/components): render project actions inside the chat input

This commit is contained in:
Jaayden Halko
2026-10-15 22:09:17 +00:00
parent 9afe083d7b
commit 9f984f9c82
7 changed files with 85 additions and 89 deletions
@@ -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) {