mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Merge branch 'main' into bobby/plat-550-expose-skipped-canceled-outcomes
This commit is contained in:
@@ -2778,7 +2778,8 @@ export const chatUserCustomPrompt = () => ({
|
||||
|
||||
export const updateUserChatCustomPrompt = (queryClient: QueryClient) => ({
|
||||
mutationFn: API.experimental.updateUserChatCustomPrompt,
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (data: TypesGen.UserChatCustomPrompt) => {
|
||||
queryClient.setQueryData(chatUserCustomPromptKey, data);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: chatUserCustomPromptKey,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ const AgentSettingsGeneralPage: React.FC = () => {
|
||||
return (
|
||||
<AgentSettingsGeneralPageView
|
||||
userPromptData={userPromptQuery.data}
|
||||
isLoadingUserPrompt={userPromptQuery.isLoading}
|
||||
userPromptError={userPromptQuery.error}
|
||||
onSaveUserPrompt={saveUserPromptMutation.mutate}
|
||||
isSavingUserPrompt={saveUserPromptMutation.isPending}
|
||||
isSaveUserPromptError={saveUserPromptMutation.isError}
|
||||
|
||||
@@ -20,6 +20,8 @@ const baseArgs: AgentSettingsGeneralPageViewProps = {
|
||||
userPromptData: {
|
||||
custom_prompt: "Prefer concise answers with clear next steps.",
|
||||
},
|
||||
isLoadingUserPrompt: false,
|
||||
userPromptError: undefined,
|
||||
onSaveUserPrompt: fn(),
|
||||
isSavingUserPrompt: false,
|
||||
isSaveUserPromptError: false,
|
||||
@@ -38,7 +40,7 @@ const meta = {
|
||||
component: AgentSettingsGeneralPageView,
|
||||
args: baseArgs,
|
||||
parameters: {
|
||||
queries: [{ key: ["me", "preferences"], data: preferencesData }],
|
||||
queries: [{ key: preferenceSettingsKey, data: preferencesData }],
|
||||
},
|
||||
} satisfies Meta<typeof AgentSettingsGeneralPageView>;
|
||||
|
||||
@@ -47,6 +49,23 @@ type Story = StoryObj<typeof AgentSettingsGeneralPageView>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const LoadingPersonalInstructions: Story = {
|
||||
args: { userPromptData: undefined, isLoadingUserPrompt: true },
|
||||
};
|
||||
|
||||
export const PersonalInstructionsLoadError: Story = {
|
||||
args: {
|
||||
userPromptData: undefined,
|
||||
userPromptError: new Error(
|
||||
"Unable to load personal instructions. Please reload the page.",
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyPersonalInstructions: Story = {
|
||||
args: { userPromptData: { custom_prompt: "" } },
|
||||
};
|
||||
|
||||
export const InvisibleUnicodeWarningUserPrompt: Story = {
|
||||
args: {
|
||||
userPromptData: {
|
||||
|
||||
@@ -14,6 +14,8 @@ import { UserChatDebugLoggingSettings } from "./components/UserChatDebugLoggingS
|
||||
|
||||
export type AgentSettingsGeneralPageViewProps = {
|
||||
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
|
||||
isLoadingUserPrompt: boolean;
|
||||
userPromptError: unknown;
|
||||
onSaveUserPrompt: UseMutateFunction<
|
||||
TypesGen.UserChatCustomPrompt,
|
||||
Error,
|
||||
@@ -37,6 +39,8 @@ export const AgentSettingsGeneralPageView: React.FC<
|
||||
AgentSettingsGeneralPageViewProps
|
||||
> = ({
|
||||
userPromptData,
|
||||
isLoadingUserPrompt,
|
||||
userPromptError,
|
||||
onSaveUserPrompt,
|
||||
isSavingUserPrompt,
|
||||
isSaveUserPromptError,
|
||||
@@ -53,6 +57,8 @@ export const AgentSettingsGeneralPageView: React.FC<
|
||||
/>
|
||||
<PersonalInstructionsSettings
|
||||
userPromptData={userPromptData}
|
||||
isLoadingUserPrompt={isLoadingUserPrompt}
|
||||
userPromptError={userPromptError}
|
||||
onSaveUserPrompt={onSaveUserPrompt}
|
||||
isSavingUserPrompt={isSavingUserPrompt}
|
||||
isSaveUserPromptError={isSaveUserPromptError}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { API } from "#/api/api";
|
||||
import { chatUserCustomPrompt } from "#/api/queries/chats";
|
||||
import { preferenceSettingsKey } from "#/api/queries/users";
|
||||
import type { UserChatCustomPrompt } from "#/api/typesGenerated";
|
||||
import { createDeferred } from "#/testHelpers/deferred";
|
||||
import { MockUserPreferenceSettings } from "#/testHelpers/entities";
|
||||
import {
|
||||
createTestQueryClient,
|
||||
renderComponent,
|
||||
} from "#/testHelpers/renderHelpers";
|
||||
import AgentSettingsGeneralPage from "../AgentSettingsGeneralPage";
|
||||
|
||||
const renderPage = (custom_prompt?: string) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
if (custom_prompt !== undefined) {
|
||||
queryClient.setQueryData(chatUserCustomPrompt().queryKey, {
|
||||
custom_prompt,
|
||||
});
|
||||
}
|
||||
queryClient.setQueryData(preferenceSettingsKey, MockUserPreferenceSettings);
|
||||
renderComponent(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentSettingsGeneralPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(API.experimental, "updateUserChatCustomPrompt").mockImplementation(
|
||||
async (values) => values,
|
||||
);
|
||||
vi.spyOn(API, "getUserPreferenceSettings").mockResolvedValue(
|
||||
MockUserPreferenceSettings,
|
||||
);
|
||||
vi.spyOn(API.experimental, "getUserChatDebugLogging").mockResolvedValue({
|
||||
debug_logging_enabled: false,
|
||||
user_toggle_allowed: false,
|
||||
forced_by_deployment: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PersonalInstructionsSettings", () => {
|
||||
it.each(["Existing saved instructions", ""])(
|
||||
"blocks editing until the initial prompt %j loads, then saves edits",
|
||||
async (custom_prompt) => {
|
||||
const user = userEvent.setup();
|
||||
const pending = createDeferred<UserChatCustomPrompt>();
|
||||
vi.spyOn(API.experimental, "getUserChatCustomPrompt").mockReturnValue(
|
||||
pending.promise,
|
||||
);
|
||||
const queryClient = renderPage();
|
||||
const textarea = screen.getByRole("textbox", {
|
||||
name: "Personal instructions",
|
||||
});
|
||||
await user.type(textarea, "Premature draft");
|
||||
expect(textarea).toHaveValue("");
|
||||
expect(
|
||||
API.experimental.updateUserChatCustomPrompt,
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => pending.resolve({ custom_prompt }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryState(chatUserCustomPrompt().queryKey)?.status,
|
||||
).toBe("success"),
|
||||
);
|
||||
await user.type(textarea, " and my edits");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
API.experimental.updateUserChatCustomPrompt,
|
||||
).toHaveBeenCalledWith({
|
||||
custom_prompt: `${custom_prompt} and my edits`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks editing after an initial failure", async () => {
|
||||
vi.spyOn(API.experimental, "getUserChatCustomPrompt").mockRejectedValue(
|
||||
new Error("Unable to load personal instructions"),
|
||||
);
|
||||
const queryClient = renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryState(chatUserCustomPrompt().queryKey)?.status,
|
||||
).toBe("error"),
|
||||
);
|
||||
const textarea = screen.getByRole("textbox", {
|
||||
name: "Personal instructions",
|
||||
});
|
||||
await userEvent.type(textarea, "Premature draft");
|
||||
expect(textarea).toHaveValue("");
|
||||
expect(API.experimental.updateUserChatCustomPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["typed", "cleared"])(
|
||||
"preserves %s dirty edits through a background query update",
|
||||
async (draft) => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.spyOn(API.experimental, "getUserChatCustomPrompt")
|
||||
.mockResolvedValue({ custom_prompt: "Saved instructions" });
|
||||
const queryClient = renderPage("Saved instructions");
|
||||
const textarea = screen.getByRole("textbox", {
|
||||
name: "Personal instructions",
|
||||
});
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "My draft");
|
||||
if (draft === "cleared") {
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
}
|
||||
load.mockResolvedValue({ custom_prompt: "Updated elsewhere" });
|
||||
await act(async () => {
|
||||
await queryClient.refetchQueries(chatUserCustomPrompt());
|
||||
});
|
||||
await user.type(textarea, " with more edits");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
API.experimental.updateUserChatCustomPrompt,
|
||||
).toHaveBeenCalledWith({
|
||||
custom_prompt: `${draft === "typed" ? "My draft" : ""} with more edits`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["success", "failure"])(
|
||||
"keeps the server-confirmed save when the follow-up GET is a %s",
|
||||
async (outcome) => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.spyOn(API.experimental, "getUserChatCustomPrompt")
|
||||
.mockResolvedValue({ custom_prompt: "Original instructions" });
|
||||
vi.mocked(API.experimental.updateUserChatCustomPrompt).mockResolvedValue({
|
||||
custom_prompt: "Clean instructions",
|
||||
});
|
||||
const queryClient = renderPage("Original instructions");
|
||||
const textarea = screen.getByRole("textbox", {
|
||||
name: "Personal instructions",
|
||||
});
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Clean\u200b instructions");
|
||||
if (outcome === "failure") {
|
||||
load.mockRejectedValue(new Error("Refresh failed"));
|
||||
} else {
|
||||
load.mockResolvedValue({ custom_prompt: "Clean instructions" });
|
||||
}
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
API.experimental.updateUserChatCustomPrompt,
|
||||
).toHaveBeenCalledWith({ custom_prompt: "Clean\u200b instructions" }),
|
||||
);
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0));
|
||||
expect(queryClient.getQueryData(chatUserCustomPrompt().queryKey)).toEqual(
|
||||
{
|
||||
custom_prompt: "Clean instructions",
|
||||
},
|
||||
);
|
||||
expect(textarea).toHaveValue("Clean instructions");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { cn } from "cn";
|
||||
import { useFormik } from "formik";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { InvisibleCharactersAlert } from "#/components/Alert/InvisibleCharactersAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
@@ -12,12 +13,14 @@ import {
|
||||
} from "#/components/TemporarySavedState/TemporarySavedState";
|
||||
|
||||
type MutationCallbacks = {
|
||||
onSuccess?: () => void;
|
||||
onSuccess?: (data: TypesGen.UserChatCustomPrompt) => void;
|
||||
onError?: () => void;
|
||||
};
|
||||
|
||||
type PersonalInstructionsSettingsProps = {
|
||||
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
|
||||
isLoadingUserPrompt: boolean;
|
||||
userPromptError: unknown;
|
||||
onSaveUserPrompt: (
|
||||
req: TypesGen.UserChatCustomPrompt,
|
||||
options?: MutationCallbacks,
|
||||
@@ -31,11 +34,17 @@ export const PersonalInstructionsSettings: React.FC<
|
||||
PersonalInstructionsSettingsProps
|
||||
> = ({
|
||||
userPromptData,
|
||||
isLoadingUserPrompt,
|
||||
userPromptError,
|
||||
onSaveUserPrompt,
|
||||
isSavingUserPrompt,
|
||||
isSaveUserPromptError,
|
||||
isAnyPromptSaving,
|
||||
}) => {
|
||||
const headingId = useId();
|
||||
const [hasDraft, setHasDraft] = useState(false);
|
||||
const hasLoadedPrompt = userPromptData !== undefined;
|
||||
const isFormDisabled = isAnyPromptSaving || !hasLoadedPrompt;
|
||||
const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false);
|
||||
const { isSavedVisible, showSavedState } = useTemporarySavedState();
|
||||
|
||||
@@ -43,29 +52,54 @@ export const PersonalInstructionsSettings: React.FC<
|
||||
initialValues: {
|
||||
custom_prompt: userPromptData?.custom_prompt ?? "",
|
||||
},
|
||||
enableReinitialize: true,
|
||||
enableReinitialize: !hasDraft,
|
||||
onSubmit: (values, helpers) => {
|
||||
if (isFormDisabled) {
|
||||
return;
|
||||
}
|
||||
onSaveUserPrompt(
|
||||
{ custom_prompt: values.custom_prompt },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
showSavedState();
|
||||
helpers.resetForm();
|
||||
helpers.resetForm({ values: data });
|
||||
setHasDraft(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const changePrompt = (customPrompt: string) => {
|
||||
setHasDraft(customPrompt !== form.initialValues.custom_prompt);
|
||||
void form.setFieldValue("custom_prompt", customPrompt);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
|
||||
<h3 className="m-0 text-sm font-semibold text-content-primary">
|
||||
<h3
|
||||
id={headingId}
|
||||
className="m-0 text-sm font-semibold text-content-primary"
|
||||
>
|
||||
Personal instructions
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Applied to all your conversations. Only visible to you.
|
||||
</p>
|
||||
{!hasLoadedPrompt &&
|
||||
(isLoadingUserPrompt ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 text-xs text-content-secondary"
|
||||
>
|
||||
<Spinner loading className="size-4" />
|
||||
Loading personal instructions...
|
||||
</div>
|
||||
) : userPromptError ? (
|
||||
<ErrorAlert error={userPromptError} showDebugDetail={false} />
|
||||
) : null)}
|
||||
<TextareaAutosize
|
||||
aria-labelledby={headingId}
|
||||
className={cn(
|
||||
"max-h-[240px] w-full resize-none rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-sm leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-hidden focus:ring-2 focus:ring-content-link",
|
||||
isUserPromptOverflowing && "overflow-y-auto scrollbar-thin",
|
||||
@@ -73,9 +107,9 @@ export const PersonalInstructionsSettings: React.FC<
|
||||
name="custom_prompt"
|
||||
placeholder="Additional behavior, style, and tone preferences"
|
||||
value={form.values.custom_prompt}
|
||||
onChange={form.handleChange}
|
||||
onChange={(event) => changePrompt(event.target.value)}
|
||||
onHeightChange={(height) => setIsUserPromptOverflowing(height >= 240)}
|
||||
disabled={isAnyPromptSaving}
|
||||
disabled={isFormDisabled}
|
||||
minRows={1}
|
||||
/>
|
||||
<InvisibleCharactersAlert text={form.values.custom_prompt} />
|
||||
@@ -89,15 +123,15 @@ export const PersonalInstructionsSettings: React.FC<
|
||||
size="xs"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => form.setFieldValue("custom_prompt", "")}
|
||||
disabled={isAnyPromptSaving || !form.values.custom_prompt}
|
||||
onClick={() => changePrompt("")}
|
||||
disabled={isFormDisabled || !form.values.custom_prompt}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
disabled={isAnyPromptSaving || !form.dirty}
|
||||
disabled={isFormDisabled || !form.dirty}
|
||||
>
|
||||
{isSavingUserPrompt && <Spinner loading className="size-4" />}
|
||||
Save
|
||||
|
||||
Reference in New Issue
Block a user