feat(site/src/pages/AgentsPage): add pursue goal composer mode

This commit is contained in:
Michael Suchacz
2026-05-27 14:54:27 +00:00
parent c56016a77a
commit 8e14ccd878
10 changed files with 337 additions and 437 deletions
@@ -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);
+17 -64
View File
@@ -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<void>;
onDeleteQueuedMessage: (id: number) => Promise<void>;
chatInputRef: React.RefObject<ChatMessageInputRef | null>;
@@ -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<ReturnType<typeof parseGoalCommand>>,
): 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}
+28 -12
View File
@@ -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<void>;
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<AgentChatPageViewProps> = ({
persistedError,
isArchived,
chatOwner,
canUpdateOtherUserChat,
canUpdateOtherUserChatLoading,
canShareChat,
workspaceAgent,
workspace,
@@ -443,10 +449,14 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
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 = (
<title>
@@ -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>{" "}
@@ -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) =>
@@ -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}
@@ -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,
});
},
};
@@ -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({
@@ -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}
@@ -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();
});
});
@@ -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 },
};
};