Merge branch 'main' into bobby/plat-550-expose-skipped-canceled-outcomes

This commit is contained in:
Bobby Ho
2026-10-15 15:20:49 -07:00
committed by GitHub
6 changed files with 248 additions and 13 deletions
+2 -1
View File
@@ -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