From 9f984f9c82b0e244de6d2b23cf5cf241c3513bcc Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 15 Oct 2026 21:59:11 +0000 Subject: [PATCH] refactor(site/src/pages/AgentsPage/components): render project actions inside the chat input --- .../AgentsPage/components/AgentChatInput.tsx | 24 +++++- .../components/AgentCreateForm.stories.tsx | 16 ++++ .../AgentsPage/components/AgentCreateForm.tsx | 17 +--- .../AgentsPage/components/ChatPageContent.tsx | 14 +--- .../ProjectSkillActions.stories.tsx | 10 ++- .../components/ProjectSkillActions.test.tsx | 84 +++++++------------ .../components/ProjectSkillActions.tsx | 9 +- 7 files changed, 85 insertions(+), 89 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index b4a3573233..0d12872bff 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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; @@ -534,7 +535,7 @@ export const AgentChatInput: React.FC = ({ chatProjectId, isChatProjectPending, isWorkspaceLoading, - actionBar, + showProjectActions = false, queuedMessages = [], onDeleteQueuedMessage, onPromoteQueuedMessage, @@ -1350,7 +1351,22 @@ export const AgentChatInput: React.FC = ({ isEditingHistoryMessage && "pt-1", )} > - {actionBar} + {showProjectActions && + experiments.includes("chat-projects") && + chatOrganizationId && + chatProjectId && + !isReadOnly && + !isReadOnlyView && + hasModelOptions && + !aiGatewayDisabled && ( + + internalRef.current?.insertSkill("project", name) + } + /> + )} {queuedMessages.length > 0 && ( ({ ...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 diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 117c9f553d..530b495349 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -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 = ({ mcpServers={mcpServers} chatOrganizationId={organizationId} chatProjectId={chatProjectId} - actionBar={ - project && - experiments.includes("chat-projects") && - !isForbidden && - hasModelOptions && - !aiGatewayDisabled && ( - - chatInputRef.current?.insertSkill("project", name) - } - /> - ) - } + showProjectActions={project !== undefined} selectedMCPServerIds={effectiveMCPServerIds} onMCPSelectionChange={(ids) => { setUserMCPServerIds(ids); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index c6a4ce7e35..5a69d01d90 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -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["store"]; @@ -852,18 +851,7 @@ export const ChatPageInput: React.FC = ({ chatOrganizationId={organizationId} chatProjectId={chatProjectId} isChatProjectPending={!isChatProjectResolved} - actionBar={ - chatProjectId && - !isReadOnly && - hasModelOptions && - !aiGatewayDisabled && ( - inputRef.current?.insertSkill("project", name)} - /> - ) - } + showProjectActions selectedWorkspaceId={selectedWorkspaceId} onWorkspaceChange={onWorkspaceChange} isWorkspaceLoading={workspacesQuery.isLoading || isWorkspaceLoading} diff --git a/site/src/pages/AgentsPage/components/ProjectSkillActions.stories.tsx b/site/src/pages/AgentsPage/components/ProjectSkillActions.stories.tsx index c2941012c8..80eee2b2e0 100644 --- a/site/src/pages/AgentsPage/components/ProjectSkillActions.stories.tsx +++ b/site/src/pages/AgentsPage/components/ProjectSkillActions.stories.tsx @@ -32,7 +32,7 @@ const meta: Meta = { }, decorators: [ (Story) => ( -
+
), @@ -42,6 +42,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; +/** 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: [ diff --git a/site/src/pages/AgentsPage/components/ProjectSkillActions.test.tsx b/site/src/pages/AgentsPage/components/ProjectSkillActions.test.tsx index f60f49b7ef..93e3e48bee 100644 --- a/site/src/pages/AgentsPage/components/ProjectSkillActions.test.tsx +++ b/site/src/pages/AgentsPage/components/ProjectSkillActions.test.tsx @@ -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 => ({ - ...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( - + { , ); - 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([]); }); }); diff --git a/site/src/pages/AgentsPage/components/ProjectSkillActions.tsx b/site/src/pages/AgentsPage/components/ProjectSkillActions.tsx index f6121f091c..be01ec3ebb 100644 --- a/site/src/pages/AgentsPage/components/ProjectSkillActions.tsx +++ b/site/src/pages/AgentsPage/components/ProjectSkillActions.tsx @@ -113,8 +113,13 @@ const ProjectSkillActionButton: React.FC = ({ onInvoke, }) => { const button = ( - ); if (!skill.description) {