diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 3e9d8d9b04..7da4be9304 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -782,6 +782,28 @@ describe("useConversationEditingState", () => { unmount(); }); + it("forwards goal mutation options for a new message", async () => { + const { result, onSend, unmount } = renderEditing(); + const mockInput = createMockChatInputHandle("ship it"); + result.current.chatInputRef.current = mockInput.handle; + const options = { + goalMutation: { action: "set" as const, objective: "ship it" }, + }; + + await act(async () => { + await result.current.handleSendFromInput("ship it", undefined, options); + }); + + expect(onSend).toHaveBeenCalledWith( + "ship it", + undefined, + undefined, + options, + ); + expect(mockInput.clear).toHaveBeenCalled(); + unmount(); + }); + it("does not write a draft key when chatID is undefined", () => { const { result, unmount } = renderEditing(undefined); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index e57a75e8ec..781c533ecb 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -20,7 +20,6 @@ import { buildOptimisticEditedMessage } from "#/api/queries/chatMessageEdits"; import { chat, chatDesktopEnabled, - chatGoal, chatKey, chatMessagesForInfiniteScroll, chatModelConfigs, @@ -65,7 +64,10 @@ import { AgentChatPageView, } from "./AgentChatPageView"; import type { AgentsOutletContext } from "./AgentsPage"; -import type { ChatMessageInputRef } from "./components/AgentChatInput"; +import type { + AgentChatInputSendOptions, + ChatMessageInputRef, +} from "./components/AgentChatInput"; import { AgentSetupNotice } from "./components/AgentSetupNotice"; import { normalizeChatErrorPayload } from "./components/ChatConversation/chatError"; import { @@ -90,7 +92,6 @@ import { getModelSelectorHelp } from "./components/ModelSelectorHelp"; import { useGitWatcher } from "./hooks/useGitWatcher"; import { getAgentChatSendShortcut } from "./utils/agentChatSendShortcut"; import { type ParsedDraft, parseStoredDraft } from "./utils/draftStorage"; -import { parseGoalCommand } from "./utils/goalCommand"; import { countConfiguredProviderConfigs, getModelOptionsFromConfigs, @@ -385,6 +386,7 @@ export function useConversationEditingState(deps: { message: string, attachments?: readonly PendingAttachment[], editedMessageID?: number, + options?: AgentChatInputSendOptions, ) => Promise; onDeleteQueuedMessage: (id: number) => Promise; chatInputRef: React.RefObject; @@ -582,11 +584,15 @@ export function useConversationEditingState(deps: { const handleSendFromInput = async ( message: string, attachments?: readonly PendingAttachment[], + options?: AgentChatInputSendOptions, ) => { const editedMessageID = editingMessageId !== null ? editingMessageId : undefined; const queueEditID = editingQueuedMessageID; - const sendPromise = onSend(message, attachments, editedMessageID); + const sendPromise = + options === undefined + ? onSend(message, attachments, editedMessageID) + : onSend(message, attachments, editedMessageID, options); // For history edits, clear input immediately and prepare // a rollback in case the send fails. @@ -1284,49 +1290,6 @@ const AgentChatPage: FC = () => { }); }; - const handleGoalCommand = async ( - command: NonNullable>, - ): Promise<{ - message: string; - mutation?: TypesGen.ChatGoalMutation; - } | null> => { - if (!agentId) { - return null; - } - switch (command.kind) { - case "set": - if (!canMutateGoal) { - toast.warning("Goals can only be changed from the root chat."); - return null; - } - return { message: command.objective, mutation: command.mutation }; - case "show": { - const response = await queryClient - .fetchQuery(chatGoal(agentId)) - .catch((error) => { - toast.error("Failed to fetch goal."); - throw error; - }); - setCachedChatGoal(queryClient, agentId, response.goal); - toast.info( - response.goal - ? `Current goal: ${response.goal.objective}` - : "No current goal.", - ); - return null; - } - case "lifecycle": - await handleGoalAction( - command.action, - command.mutation.completion_summary, - ); - return null; - case "unsupported": - toast.warning(command.reason); - return null; - } - }; - const handleUsageLimitError = (error: unknown): void => { if (!agentId) { return; @@ -1690,28 +1653,16 @@ const AgentChatPage: FC = () => { message: string, attachments?: readonly PendingAttachment[], editedMessageID?: number, + options?: AgentChatInputSendOptions, ) { - if (editedMessageID === undefined) { - const goalCommand = parseGoalCommand(message); - if (goalCommand) { - const goalSubmission = await handleGoalCommand(goalCommand); - if (!goalSubmission) { - return; - } - await submitChatTurn({ - message: goalSubmission.message, - attachments, - goalMutation: goalSubmission.mutation, - useComposerContent: false, - }); - return; - } - } - await submitChatTurn({ message, attachments, editedMessageID, + goalMutation: + editedMessageID === undefined && canMutateGoal && !isGoalActionDisabled + ? options?.goalMutation + : undefined, }); } @@ -1784,6 +1735,8 @@ const AgentChatPage: FC = () => { persistedError={persistedError} isArchived={isArchived} chatOwner={chatOwner} + canUpdateOtherUserChat={canUpdateOtherUserChat} + canUpdateOtherUserChatLoading={canUpdateOtherUserChatLoading} canShareChat={canShareChat} workspace={workspace} workspaceAgent={workspaceAgent} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 48338c99c2..c98b60b9de 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -20,6 +20,7 @@ import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; import { AgentChatInput, + type AgentChatInputSendOptions, type ChatMessageInputRef, } from "./components/AgentChatInput"; import { @@ -85,7 +86,8 @@ interface EditingState { handleSendFromInput: ( message: string, attachments?: readonly PendingAttachment[], - ) => void; + options?: AgentChatInputSendOptions, + ) => Promise; handleContentChange: ( content: string, serializedEditorState: string, @@ -103,6 +105,8 @@ interface AgentChatPageViewProps { persistedError: ChatDetailError | undefined; isArchived: boolean; chatOwner: ChatOwnerInfo | undefined; + canUpdateOtherUserChat: boolean; + canUpdateOtherUserChatLoading: boolean; canShareChat: boolean; workspaceAgent?: TypesGen.WorkspaceAgent; workspace?: TypesGen.Workspace; @@ -215,6 +219,8 @@ export const AgentChatPageView: FC = ({ persistedError, isArchived, chatOwner, + canUpdateOtherUserChat, + canUpdateOtherUserChatLoading, canShareChat, workspaceAgent, workspace, @@ -443,10 +449,14 @@ export const AgentChatPageView: FC = ({ const chatOwnerLabel = chatOwner?.name?.trim() || (chatOwnerUsername ? `@${chatOwnerUsername}` : "another user"); - const isOtherUserReadOnly = !isArchived && chatOwner !== undefined; - const chatOwnerWarning = isOtherUserReadOnly - ? `This chat is owned by ${chatOwnerLabel}. It is read-only.` - : undefined; + const chatOwnerWarning = + !isArchived && chatOwner !== undefined && !canUpdateOtherUserChatLoading + ? canUpdateOtherUserChat + ? `This is not your chat. Prompting here will use ${chatOwnerLabel}'s identity.` + : `This chat is owned by ${chatOwnerLabel}. You have read-only access.` + : undefined; + const topGoal = + goal?.status === "active" || goal?.status === "paused" ? goal : undefined; const titleElement = ( @@ -525,6 +535,17 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({ This agent has been archived and is read-only. </div> )} + {topGoal && ( + <div className="shrink-0 px-4 pt-3"> + <ChatGoalBanner + goal={topGoal} + canMutateGoal={canMutateGoal} + isActionPending={isGoalActionPending} + isActionDisabled={isGoalActionDisabled} + onAction={onGoalAction} + /> + </div> + )} <div aria-hidden className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary" @@ -569,13 +590,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({ </div> </ChatScrollContainer> <div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]"> - <ChatGoalBanner - goal={goal} - canMutateGoal={canMutateGoal} - isActionPending={isGoalActionPending} - isActionDisabled={isGoalActionDisabled} - onAction={onGoalAction} - /> <ChatPageInput organizationId={organizationId} sendShortcut={sendShortcut} @@ -597,6 +611,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({ agentSetupNotice={agentSetupNotice} planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} + canPursueGoal={canMutateGoal && !isGoalActionDisabled} isModelCatalogLoading={isModelCatalogLoading} workspaceOptions={workspaceOptions} chatOrganizationId={organizationId} @@ -739,6 +754,7 @@ export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({ planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} isModelCatalogLoading={isModelCatalogLoading} + canPursueGoal={false} hasModelOptions={hasModelOptions} /> </div>{" "} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 375cf88430..c512758213 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -211,6 +211,56 @@ export const SendsAndClearsInput: Story = { }, }; +export const PursueGoalModeSendsGoalMutation: Story = { + args: { + onSend: fn().mockResolvedValue(undefined), + initialValue: " stabilize the release ", + onPlanModeToggle: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + await userEvent.click( + within(document.body).getByRole("menuitemcheckbox", { + name: "Pursue goal", + }), + ); + + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + expect( + within(document.body).getByRole("menuitemcheckbox", { + name: "Pursue goal", + }), + ).toHaveAttribute("aria-checked", "true"); + expect( + within(document.body).getByRole("menuitemcheckbox", { + name: "Plan first", + }), + ).toBeDisabled(); + await userEvent.keyboard("{Escape}"); + + await waitFor(() => { + expect(canvas.getByRole("button", { name: "Send" })).toBeEnabled(); + }); + await userEvent.click(canvas.getByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(args.onSend).toHaveBeenCalledWith("stabilize the release", { + goalMutation: { + action: "set", + objective: "stabilize the release", + }, + }); + }); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + expect( + within(document.body).getByRole("menuitemcheckbox", { + name: "Pursue goal", + }), + ).toHaveAttribute("aria-checked", "false"); + }, +}; + export const EnterSendsByDefault: Story = { args: { onSend: fn(), @@ -570,6 +620,36 @@ export const AttachmentsOnly: Story = { })(), }; +export const AttachmentsOnlyPursueGoalBlocksSend: Story = { + args: (() => { + const file = createMockFile("photo.png", "image/png"); + return { + attachments: [file], + uploadStates: new Map<File, UploadState>([ + [file, { status: "uploaded", fileId: "f-only" }], + ]), + previewUrls: new Map<File, string>([[file, TINY_PNG]]), + onAttach: fn(), + onRemoveAttachment: fn(), + onSend: fn(), + initialValue: "", + }; + })(), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + await userEvent.click( + within(document.body).getByRole("menuitemcheckbox", { + name: "Pursue goal", + }), + ); + + const sendButton = canvas.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); + expect(args.onSend).not.toHaveBeenCalled(); + }, +}; + const LARGE_PASTE_MARKER = "__PASTE_MARKER_TEST__"; const largePasteText = Array.from({ length: 12 }, (_, i) => diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 6ddafd1fa8..ca59f8390e 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -10,6 +10,7 @@ import { PlusIcon, ServerIcon, SquareIcon, + TargetIcon, XIcon, } from "lucide-react"; import type React from "react"; @@ -92,8 +93,15 @@ export { export type { ChatMessageInputRef } from "./ChatMessageInput/ChatMessageInput"; export type { AgentContextUsage } from "./ContextUsageIndicator"; +export type AgentChatInputSendOptions = { + goalMutation?: TypesGen.ChatGoalMutation; +}; + interface AgentChatInputProps { - onSend: (message: string) => void; + onSend: ( + message: string, + options?: AgentChatInputSendOptions, + ) => Promise<void> | void; sendShortcut?: AgentChatSendShortcut; placeholder?: string; isDisabled: boolean; @@ -121,6 +129,7 @@ interface AgentChatInputProps { hasModelOptions: boolean; planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; + canPursueGoal?: boolean; isModelCatalogLoading?: boolean; // Streaming controls (optional, for the detail page). isStreaming?: boolean; @@ -319,6 +328,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ hasModelOptions, planModeEnabled = false, onPlanModeToggle, + canPursueGoal = true, isModelCatalogLoading = false, isStreaming = false, onInterrupt, @@ -373,6 +383,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ ); const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false); const [mcpConnectingId, setMcpConnectingId] = useState<string | null>(null); + const [pursueGoalEnabled, setPursueGoalEnabled] = useState(false); const mcpPopupRef = useRef<Window | null>(null); const [hasFileReferences, setHasFileReferences] = useState(false); @@ -549,12 +560,40 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ const handleRemoveMcp = (serverId: string) => handleMcpToggle(serverId, false); + const isGoalModeUnavailable = + !canPursueGoal || + editingQueuedMessageID !== null || + isEditingHistoryMessage; + + useEffect(() => { + if (pursueGoalEnabled && isGoalModeUnavailable) { + setPursueGoalEnabled(false); + } + }, [pursueGoalEnabled, isGoalModeUnavailable]); + const handlePlanModeToggle = () => { - onPlanModeToggle?.(!planModeEnabled); + const nextEnabled = !planModeEnabled; + if (nextEnabled) { + setPursueGoalEnabled(false); + } + onPlanModeToggle?.(nextEnabled); + setPlusMenuOpen(false); + }; + + const handleGoalModeToggle = () => { + if (isDisabled || isGoalModeUnavailable) { + return; + } + const nextEnabled = !pursueGoalEnabled; + setPursueGoalEnabled(nextEnabled); + if (nextEnabled && planModeEnabled) { + onPlanModeToggle?.(false); + } setPlusMenuOpen(false); }; const handleDisablePlanMode = () => onPlanModeToggle?.(false); + const handleDisableGoalMode = () => setPursueGoalEnabled(false); const fileInputRef = useRef<HTMLInputElement>(null); const [composerElement, setComposerElement] = useState<HTMLDivElement | null>( @@ -804,15 +843,16 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ const hasDraftContext = hasContent || attachments.length > 0 || hasFileReferences; const isComposerEffectivelyEmpty = !hasDraftContext; - const hasSendableContent = - hasContent || hasUploadedAttachments || hasFileReferences; + const hasSendableContent = pursueGoalEnabled + ? hasContent + : hasContent || hasUploadedAttachments || hasFileReferences; const canSend = !isDisabled && !isLoading && hasModelOptions && hasSendableContent && !hasActiveUploads; - const handleSubmit = () => { + const handleSubmit = async () => { const text = internalRef.current?.getValue()?.trim() ?? ""; // If the input is empty and there are queued messages, @@ -841,7 +881,24 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ return; } - onSend(text); + if (pursueGoalEnabled && !text) { + return; + } + + try { + if (pursueGoalEnabled) { + await onSend(text, { + goalMutation: { action: "set", objective: text }, + }); + } else { + await onSend(text); + } + } catch { + return; + } + if (pursueGoalEnabled) { + setPursueGoalEnabled(false); + } resetPromptCycle(); if (!isMobileViewport()) { internalRef.current?.focus(); @@ -1091,7 +1148,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ remountKey={remountKey} onChange={handleContentChange} onKeyDown={handleEditorKeyDown} - onEnter={handleSubmit} + onEnter={() => { + void handleSubmit(); + }} sendShortcut={sendShortcut} disabled={isDisabled || isLoading} autoFocus @@ -1201,7 +1260,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ role="menuitemcheckbox" aria-checked={planModeEnabled} onClick={handlePlanModeToggle} - disabled={isDisabled} + disabled={isDisabled || pursueGoalEnabled} className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50" > <PencilIcon className="size-3.5 shrink-0" /> @@ -1211,6 +1270,20 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ )} </button> )} + <button + type="button" + role="menuitemcheckbox" + aria-checked={pursueGoalEnabled} + onClick={handleGoalModeToggle} + disabled={isDisabled || isGoalModeUnavailable} + className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50" + > + <TargetIcon className="size-3.5 shrink-0" /> + <span>Pursue goal</span> + {pursueGoalEnabled && ( + <CheckIcon className="ml-auto size-icon-sm shrink-0" /> + )} + </button> {workspaceOptions && onWorkspaceChange && (isBelowMdViewport() ? ( @@ -1357,6 +1430,17 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ )} </span> )}{" "} + {pursueGoalEnabled && ( + <span className="hidden shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary sm:inline-flex"> + <TargetIcon className="size-3" /> + Pursuing goal + <BadgeDismissButton + onClick={handleDisableGoalMode} + ariaLabel="Disable goal mode" + isDisabled={isDisabled} + /> + </span> + )} {/* Badge row; all badges and the pill always * render so the DOM structure never changes. * Overflow badges use invisible + order-1 to @@ -1488,7 +1572,11 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({ variant="default" className="size-7 rounded-full transition-colors [&>svg]:!size-5 [&>svg]:p-0" onClick={ - speech.isRecording ? handleAcceptRecording : handleSubmit + speech.isRecording + ? handleAcceptRecording + : () => { + void handleSubmit(); + } } disabled={speech.isRecording ? false : !canSend} aria-keyshortcuts={sendButtonKeyShortcuts} diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 50a479d3ad..fbcd516f99 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -165,7 +165,37 @@ type CreateChatSubmission = { goalMutation?: TypesGen.ChatGoalMutation; }; -export const GoalCommandCreatesGoal: Story = { +const enablePursueGoal = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + await userEvent.click( + screen.getByRole("menuitemcheckbox", { name: "Pursue goal" }), + ); +}; + +export const PursueGoalCreatesGoal: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelConfigs: defaultModelConfigs, + }, + play: async ({ canvasElement, args }) => { + await enablePursueGoal(canvasElement); + await submitMessage(canvasElement, "fix the flaky tests"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat)).toMatchObject({ + message: "fix the flaky tests", + goalMutation: { + action: "set", + objective: "fix the flaky tests", + }, + }); + }, +}; + +export const SlashGoalSubmitsAsNormalText: Story = { args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), @@ -177,11 +207,8 @@ export const GoalCommandCreatesGoal: Story = { expect(args.onCreateChat).toHaveBeenCalled(); }); expect(getCreateOptions(args.onCreateChat)).toMatchObject({ - message: "fix the flaky tests", - goalMutation: { - action: "set", - objective: "fix the flaky tests", - }, + message: "/goal fix the flaky tests", + goalMutation: undefined, }); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 47b4726e39..0c2867478f 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -21,7 +21,6 @@ import { useDashboard } from "#/modules/dashboard/useDashboard"; import { docs } from "#/utils/docs"; import { useFileAttachments } from "../hooks/useFileAttachments"; import { parseStoredDraft } from "../utils/draftStorage"; -import { parseGoalCommand } from "../utils/goalCommand"; import { getModelSelectorPlaceholder, getProviderForModelOption, @@ -32,7 +31,10 @@ import { formatUsageLimitMessage, isChatUsageLimitExceededResponse, } from "../utils/usageLimitMessage"; -import { AgentChatInput } from "./AgentChatInput"; +import { + AgentChatInput, + type AgentChatInputSendOptions, +} from "./AgentChatInput"; import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert"; import type { ModelSelectorOption } from "./ChatElements"; import { CompactOrgSelector } from "./ChatElements"; @@ -361,31 +363,19 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ ? selectedWorkspaceId : null; - const handleSend = async (message: string, fileIDs?: string[]) => { - let submittedMessage = message; - let goalMutation: TypesGen.ChatGoalMutation | undefined; - const goalCommand = parseGoalCommand(message); - if (goalCommand) { - if (goalCommand.kind === "set") { - submittedMessage = goalCommand.objective; - goalMutation = goalCommand.mutation; - } else if (goalCommand.kind === "unsupported") { - toast.warning(goalCommand.reason); - throw new Error(goalCommand.reason); - } else { - toast.info("Start a chat before using goal lifecycle commands."); - throw new Error("Start a chat before using goal lifecycle commands."); - } - } - + const handleSend = async ( + message: string, + fileIDs?: string[], + options?: AgentChatInputSendOptions, + ) => { submitDraft(); await onCreateChat({ - message: submittedMessage, + message, fileIDs, workspaceId: effectiveWorkspaceId ?? undefined, model: submittedModel, organizationId, - goalMutation, + goalMutation: options?.goalMutation, mcpServerIds: effectiveMCPServerIds.length > 0 ? [...effectiveMCPServerIds] @@ -410,7 +400,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ provider: getProviderForModelOption(modelOptions, selectedModel), }); - const handleSendWithAttachments = async (message: string) => { + const handleSendWithAttachments = async ( + message: string, + options?: AgentChatInputSendOptions, + ) => { const fileIds: string[] = []; let skippedErrors = 0; for (const file of attachments) { @@ -429,12 +422,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ ); } const fileArg = fileIds.length > 0 ? fileIds : undefined; - try { - await handleSend(message, fileArg); - resetAttachments(); - } catch { - // Attachments preserved for retry on failure. - } + await handleSend(message, fileArg, options); + resetAttachments(); }; const permittedOrgsQuery = useQuery({ diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 9599b273d1..64df4e9a83 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -14,6 +14,7 @@ import { getProviderForModelOption } from "../utils/modelOptions"; import type { ChatDetailError } from "../utils/usageLimitMessage"; import { AgentChatInput, + type AgentChatInputSendOptions, type AttachedWorkspaceInfo, type ChatMessageInputRef, isUploadInProgress, @@ -155,6 +156,7 @@ interface ChatPageInputProps { onSend: ( message: string, attachments?: readonly PendingAttachment[], + options?: AgentChatInputSendOptions, ) => Promise<void> | void; sendShortcut: AgentChatSendShortcut; onDeleteQueuedMessage: (id: number) => Promise<void>; @@ -172,6 +174,7 @@ interface ChatPageInputProps { agentSetupNotice?: ReactNode; planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; + canPursueGoal?: boolean; isModelCatalogLoading?: boolean; // Imperative editor handle plus the one-time initial draft, // owned by the conversation component. @@ -237,6 +240,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ agentSetupNotice, planModeEnabled, onPlanModeToggle, + canPursueGoal = true, isModelCatalogLoading = false, inputRef, initialValue, @@ -393,52 +397,45 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ const inputElement = ( <AgentChatInput - onSend={(message) => { - void (async () => { - const hasActiveUploads = attachments.some((file) => - isUploadInProgress(uploadStates.get(file)), + onSend={async (message, options) => { + const hasActiveUploads = attachments.some((file) => + isUploadInProgress(uploadStates.get(file)), + ); + if (hasActiveUploads) { + toast.warning("Wait for file uploads to finish before sending."); + return; + } + // Collect uploaded attachment metadata for the optimistic + // transcript builder while keeping the server payload + // shape unchanged downstream. + const pendingAttachments: PendingAttachment[] = []; + let skippedErrors = 0; + for (const file of attachments) { + const state = uploadStates.get(file); + if (state?.status === "error") { + skippedErrors++; + continue; + } + if (state?.status === "uploaded" && state.fileId) { + pendingAttachments.push({ + fileId: state.fileId, + mediaType: file.type || "application/octet-stream", + }); + } + } + if (skippedErrors > 0) { + toast.warning( + `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, ); - if (hasActiveUploads) { - toast.warning("Wait for file uploads to finish before sending."); - return; - } - // Collect uploaded attachment metadata for the optimistic - // transcript builder while keeping the server payload - // shape unchanged downstream. - const pendingAttachments: PendingAttachment[] = []; - let skippedErrors = 0; - for (const file of attachments) { - const state = uploadStates.get(file); - if (state?.status === "error") { - skippedErrors++; - continue; - } - if (state?.status === "uploaded" && state.fileId) { - pendingAttachments.push({ - fileId: state.fileId, - mediaType: file.type || "application/octet-stream", - }); - } - } - if (skippedErrors > 0) { - toast.warning( - `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, - ); - } - const attachmentArg = - pendingAttachments.length > 0 ? pendingAttachments : undefined; - try { - await onSend(message, attachmentArg); - } catch { - // Attachments preserved for retry on failure. - return; - } - if (isEditing) { - editAttachments.resetAttachments(); - } else { - composeAttachments.resetAttachments(); - } - })(); + } + const attachmentArg = + pendingAttachments.length > 0 ? pendingAttachments : undefined; + await onSend(message, attachmentArg, options); + if (isEditing) { + editAttachments.resetAttachments(); + } else { + composeAttachments.resetAttachments(); + } }} sendShortcut={sendShortcut} attachments={attachments} @@ -474,6 +471,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({ modelSelectorPlaceholder={modelSelectorPlaceholder} planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} + canPursueGoal={canPursueGoal} isModelCatalogLoading={isModelCatalogLoading} workspaceOptions={workspaceOptions} chatOrganizationId={chatOrganizationId} diff --git a/site/src/pages/AgentsPage/utils/goalCommand.test.ts b/site/src/pages/AgentsPage/utils/goalCommand.test.ts deleted file mode 100644 index 767a248936..0000000000 --- a/site/src/pages/AgentsPage/utils/goalCommand.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseGoalCommand } from "./goalCommand"; - -describe("parseGoalCommand", () => { - it("ignores non-goal messages and inline slash triggers", () => { - expect(parseGoalCommand("please /goal fix the build")).toBeNull(); - expect(parseGoalCommand("/goalie fix the build")).toBeNull(); - }); - - it("parses /goal as a show command", () => { - expect(parseGoalCommand("/goal")).toEqual({ kind: "show" }); - expect(parseGoalCommand("/goal ")).toEqual({ kind: "show" }); - }); - - it("parses an objective as a set mutation", () => { - expect(parseGoalCommand("/goal fix the flaky tests")).toEqual({ - kind: "set", - objective: "fix the flaky tests", - mutation: { action: "set", objective: "fix the flaky tests" }, - }); - }); - - it("parses escaped objectives with double dash", () => { - expect(parseGoalCommand("/goal -- clear the cache")).toEqual({ - kind: "set", - objective: "clear the cache", - mutation: { action: "set", objective: "clear the cache" }, - }); - }); - - it("parses lifecycle commands", () => { - expect(parseGoalCommand("/goal clear")).toEqual({ - kind: "lifecycle", - action: "clear", - mutation: { action: "clear" }, - }); - expect(parseGoalCommand("/goal pause")).toEqual({ - kind: "lifecycle", - action: "pause", - mutation: { action: "pause" }, - }); - expect(parseGoalCommand("/goal resume")).toEqual({ - kind: "lifecycle", - action: "resume", - mutation: { action: "resume" }, - }); - }); - - it("parses complete with and without a summary", () => { - expect(parseGoalCommand("/goal complete")).toEqual({ - kind: "lifecycle", - action: "complete", - mutation: { action: "complete" }, - }); - expect(parseGoalCommand("/goal complete --summary Fixed the bug")).toEqual({ - kind: "lifecycle", - action: "complete", - mutation: { action: "complete", completion_summary: "Fixed the bug" }, - }); - }); - - it("rejects reserved commands with extra text", () => { - expect(parseGoalCommand("/goal clear the cache")).toMatchObject({ - kind: "unsupported", - }); - expect(parseGoalCommand("/goal complete the setup")).toMatchObject({ - kind: "unsupported", - }); - }); - - it("rejects empty escaped objectives and summary flags", () => { - expect(parseGoalCommand("/goal --")).toMatchObject({ - kind: "unsupported", - }); - expect(parseGoalCommand("/goal complete --summary")).toMatchObject({ - kind: "unsupported", - }); - }); - - it("rejects unsupported budget commands", () => { - expect(parseGoalCommand("/goal budget 10 turns")).toMatchObject({ - kind: "unsupported", - }); - expect(parseGoalCommand("/goal budget=10")).toMatchObject({ - kind: "unsupported", - }); - expect(parseGoalCommand("/goal --budget 10")).toMatchObject({ - kind: "unsupported", - }); - }); - - it("rejects unsupported turn cap flags", () => { - expect(parseGoalCommand("/goal --turns 5 fix it")).toMatchObject({ - kind: "unsupported", - }); - expect(parseGoalCommand("/goal --max-turns 5 fix it")).toMatchObject({ - kind: "unsupported", - }); - expect(parseGoalCommand("/goal --turn-limit 5 fix it")).toMatchObject({ - kind: "unsupported", - }); - }); - - it("keeps hyphenated objectives that start with unsupported command words", () => { - expect(parseGoalCommand("/goal budget-friendly cleanup")).toMatchObject({ - kind: "set", - objective: "budget-friendly cleanup", - }); - expect(parseGoalCommand("/goal --turn-based test plan")).toMatchObject({ - kind: "set", - objective: "--turn-based test plan", - }); - }); - - it("preserves multiline objectives", () => { - expect(parseGoalCommand("/goal fix the tests\nthen run lint")).toEqual({ - kind: "set", - objective: "fix the tests\nthen run lint", - mutation: { action: "set", objective: "fix the tests\nthen run lint" }, - }); - }); - - it("reserves a start-of-message /goal command over a personal skill name", () => { - expect(parseGoalCommand("/goal review this")).toMatchObject({ - kind: "set", - objective: "review this", - }); - expect(parseGoalCommand("Use the /goal skill inline")).toBeNull(); - }); -}); diff --git a/site/src/pages/AgentsPage/utils/goalCommand.ts b/site/src/pages/AgentsPage/utils/goalCommand.ts deleted file mode 100644 index e48bc812be..0000000000 --- a/site/src/pages/AgentsPage/utils/goalCommand.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type * as TypesGen from "#/api/typesGenerated"; - -type ParsedGoalCommand = - | { kind: "show" } - | { - kind: "set"; - objective: string; - mutation: TypesGen.ChatGoalMutation; - } - | { - kind: "lifecycle"; - action: Exclude<TypesGen.ChatGoalMutationAction, "set">; - mutation: TypesGen.ChatGoalMutation; - } - | { kind: "unsupported"; reason: string }; - -const commandPrefix = "/goal"; -const summaryFlag = "--summary"; -const budgetFlags = new Set(["--budget"]); -const turnCapFlags = new Set([ - "--turn", - "--turns", - "--max-turn", - "--max-turns", - "--turn-cap", - "--turn-limit", -]); - -const isFlagToken = (token: string, flags: ReadonlySet<string>): boolean => { - for (const flag of flags) { - if (token === flag || token.startsWith(`${flag}=`)) { - return true; - } - } - return false; -}; - -const unsupportedReservedCommand = (command: string): ParsedGoalCommand => ({ - kind: "unsupported", - reason: `Use /goal ${command} without extra text, or /goal -- ${command} ... to set an objective starting with ${command}.`, -}); - -const makeLifecycleMutation = ( - action: Exclude<TypesGen.ChatGoalMutationAction, "set">, - completionSummary?: string, -): ParsedGoalCommand => ({ - kind: "lifecycle", - action, - mutation: - completionSummary === undefined - ? { action } - : { action, completion_summary: completionSummary }, -}); - -export const parseGoalCommand = (message: string): ParsedGoalCommand | null => { - if (!message.startsWith(commandPrefix)) { - return null; - } - - const afterPrefix = message.slice(commandPrefix.length); - if (afterPrefix.length > 0 && !/^\s/.test(afterPrefix)) { - return null; - } - - const args = afterPrefix.trim(); - if (!args) { - return { kind: "show" }; - } - - const firstToken = args.split(/\s+/, 1)[0] ?? ""; - const firstTokenLower = firstToken.toLowerCase(); - if ( - firstTokenLower === "budget" || - firstTokenLower.startsWith("budget=") || - isFlagToken(firstTokenLower, budgetFlags) || - isFlagToken(firstTokenLower, turnCapFlags) - ) { - return { - kind: "unsupported", - reason: - "Goal budget and turn limit commands are not supported. Set only the objective.", - }; - } - - if (args === "--" || args.startsWith("-- ") || args.startsWith("--\n")) { - const escapedObjective = args.slice(2).trim(); - if (!escapedObjective) { - return { - kind: "unsupported", - reason: "Provide an objective after /goal --.", - }; - } - return { - kind: "set", - objective: escapedObjective, - mutation: { action: "set", objective: escapedObjective }, - }; - } - - const rest = args.slice(firstToken.length).trim(); - if (firstTokenLower === "clear") { - return rest - ? unsupportedReservedCommand("clear") - : makeLifecycleMutation("clear"); - } - if (firstTokenLower === "pause") { - return rest - ? unsupportedReservedCommand("pause") - : makeLifecycleMutation("pause"); - } - if (firstTokenLower === "resume") { - return rest - ? unsupportedReservedCommand("resume") - : makeLifecycleMutation("resume"); - } - if (firstTokenLower === "complete") { - if (!rest) { - return makeLifecycleMutation("complete"); - } - const restLower = rest.toLowerCase(); - if ( - restLower === summaryFlag || - restLower.startsWith(`${summaryFlag} `) || - restLower.startsWith(`${summaryFlag}\n`) - ) { - const summary = rest.slice(summaryFlag.length).trim(); - if (!summary) { - return { - kind: "unsupported", - reason: "Provide a summary after /goal complete --summary.", - }; - } - return makeLifecycleMutation("complete", summary); - } - return unsupportedReservedCommand("complete"); - } - - return { - kind: "set", - objective: args, - mutation: { action: "set", objective: args }, - }; -};