mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site/src/pages/AgentsPage): add pursue goal composer mode
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user