chore: improve design of agents behavior settings page (#24324)

closes CODAGT-153

FIgma design:
https://www.figma.com/design/oRDrsFh4guBgQUzLhRTPSe/agent-thoughts?node-id=60-8699&m=dev
This commit is contained in:
Jaayden Halko
2026-04-16 13:46:26 +01:00
committed by GitHub
parent 914a0f7830
commit 509784a2a3
16 changed files with 394 additions and 258 deletions
@@ -246,13 +246,13 @@ export const AgentSettingsAPIKeysPageView: FC<
onRemove,
}) => {
return (
<div className="flex flex-col gap-8">
<section>
<div>
<section className="flex flex-col gap-8">
<SectionHeader
label="Personal API Keys"
description="Add a personal API key for each provider. Your personal key takes precedence over the shared deployment key when both are available."
/>
<div className="mt-4">
<div>
{error ? (
<ErrorAlert error={error} />
) : isLoading ? (
@@ -230,10 +230,12 @@ export const DefaultAutostopSave: Story = {
expect(saveButton).toBeEnabled();
});
// Clearing to 0 should disable Save because toggle is still ON.
// Clearing back to the original value hides Save (pristine form).
await userEvent.clear(durationInput);
await waitFor(() => {
expect(saveButton).toBeDisabled();
expect(
within(ttlForm).queryByRole("button", { name: "Save" }),
).toBeNull();
});
},
};
@@ -303,10 +305,7 @@ export const DefaultAutostopSaveDisabled: Story = {
expect(durationInput).toHaveValue("2");
const ttlForm = durationInput.closest("form")!;
const saveButton = within(ttlForm).getByRole("button", {
name: "Save",
});
expect(saveButton).toBeDisabled();
expect(within(ttlForm).queryByRole("button", { name: "Save" })).toBeNull();
},
};
@@ -134,12 +134,11 @@ export const AgentSettingsBehaviorPageView: FC<
isSavingSystemPrompt || isSavingUserPrompt || isSavingPlanModeInstructions;
return (
<>
<div className="flex flex-col gap-8">
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your conversations."
/>
<PersonalInstructionsSettings
userPromptData={userPromptData}
onSaveUserPrompt={onSaveUserPrompt}
@@ -147,11 +146,7 @@ export const AgentSettingsBehaviorPageView: FC<
isSaveUserPromptError={isSaveUserPromptError}
isAnyPromptSaving={isAnyPromptSaving}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<ChatFullWidthSettings />
<hr className="my-5 border-0 border-t border-solid border-border" />
<UserCompactionThresholdSettings
modelConfigs={modelConfigsData ?? []}
modelConfigsError={modelConfigsError}
@@ -166,29 +161,25 @@ export const AgentSettingsBehaviorPageView: FC<
{/* ── Admin-only settings ── */}
{canSetSystemPrompt && (
<>
<hr className="my-5 border-0 border-t border-solid border-border" />
<SystemInstructionsSettings
systemPromptData={systemPromptData}
onSaveSystemPrompt={onSaveSystemPrompt}
isSavingSystemPrompt={isSavingSystemPrompt}
isSaveSystemPromptError={isSaveSystemPromptError}
isAnyPromptSaving={isAnyPromptSaving}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<PlanModeInstructionsSettings
planModeInstructionsData={planModeInstructionsData}
onSavePlanModeInstructions={onSavePlanModeInstructions}
isSavePlanModeInstructionsError={isSavePlanModeInstructionsError}
isAnyPromptSaving={isAnyPromptSaving}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<VirtualDesktopSettings
desktopEnabledData={desktopEnabledData}
onSaveDesktopEnabled={onSaveDesktopEnabled}
isSavingDesktopEnabled={isSavingDesktopEnabled}
isSaveDesktopEnabledError={isSaveDesktopEnabledError}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<WorkspaceAutostopSettings
workspaceTTLData={workspaceTTLData}
isWorkspaceTTLLoading={isWorkspaceTTLLoading}
@@ -197,8 +188,6 @@ export const AgentSettingsBehaviorPageView: FC<
isSavingWorkspaceTTL={isSavingWorkspaceTTL}
isSaveWorkspaceTTLError={isSaveWorkspaceTTLError}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<RetentionPeriodSettings
retentionDaysData={retentionDaysData}
isRetentionDaysLoading={isRetentionDaysLoading}
@@ -209,6 +198,6 @@ export const AgentSettingsBehaviorPageView: FC<
/>
</>
)}
</>
</div>
);
};
@@ -80,7 +80,7 @@ export const AgentSettingsTemplatesPageView: FC<
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-8">
<SectionHeader
label="Templates"
description="Restrict which templates agents can use to create workspaces. When no templates are selected, all templates are available."
@@ -9,12 +9,12 @@ import {
} from "#/components/Tooltip/Tooltip";
export const AdminBadge: FC = () => (
<TooltipProvider delayDuration={0}>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" size="sm" className="ml-auto cursor-default">
<Badge variant="default" size="sm" className="cursor-default">
<ShieldIcon className="h-3 w-3" />
Admin
Admin only
</Badge>
</TooltipTrigger>
<TooltipContent side="right">
@@ -6,12 +6,12 @@ export const ChatFullWidthSettings: FC = () => {
const [enabled, setEnabled] = useChatFullWidth();
return (
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
<div className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Chat Layout
</h3>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
<p className="m-0 flex-1 text-xs text-content-secondary">
Use full-width layout for agent chat messages, removing the default
max-width constraint.
</p>
@@ -285,7 +285,7 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
const modelConfigsUnavailable = modelConfigsData === null;
return (
<div className={cn("flex min-h-full flex-col space-y-3", className)}>
<div className={cn("flex min-h-full flex-col", className)}>
{isLoading && (
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
<Spinner className="h-4 w-4" loading />
@@ -294,7 +294,7 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
)}
{/* Content */}
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col gap-8">
{section === "providers" ? (
<ProvidersSection
sectionLabel={sectionLabel}
@@ -5,8 +5,13 @@ import TextareaAutosize from "react-textarea-autosize";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
@@ -29,10 +34,12 @@ export const PersonalInstructionsSettings: FC<
> = ({
userPromptData,
onSaveUserPrompt,
isSavingUserPrompt,
isSaveUserPromptError,
isAnyPromptSaving,
}) => {
const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const form = useFormik({
initialValues: {
@@ -42,7 +49,12 @@ export const PersonalInstructionsSettings: FC<
onSubmit: (values, helpers) => {
onSaveUserPrompt(
{ custom_prompt: values.custom_prompt },
{ onSuccess: () => helpers.resetForm() },
{
onSuccess: () => {
showSavedState();
helpers.resetForm();
},
},
);
},
});
@@ -52,16 +64,16 @@ export const PersonalInstructionsSettings: FC<
);
return (
<form className="space-y-2" onSubmit={form.handleSubmit}>
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<h3 className="m-0 text-sm font-semibold text-content-primary">
Personal Instructions
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
<p className="m-0 text-xs text-content-secondary">
Applied to all your conversations. Only visible to you.
</p>
<TextareaAutosize
className={cn(
"max-h-[240px] w-full resize-none rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30",
"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-none focus:ring-2 focus:ring-content-link",
isUserPromptOverflowing && "overflow-y-auto [scrollbar-width:thin]",
)}
name="custom_prompt"
@@ -81,23 +93,31 @@ export const PersonalInstructionsSettings: FC<
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => form.setFieldValue("custom_prompt", "")}
disabled={isAnyPromptSaving || !form.values.custom_prompt}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isAnyPromptSaving || !form.dirty}
>
Save
</Button>
<div className="mt-2 flex min-h-6 justify-end gap-2">
{(form.dirty || isSavedVisible || isSavingUserPrompt) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<>
<Button
size="xs"
variant="outline"
type="button"
onClick={() => form.setFieldValue("custom_prompt", "")}
disabled={isAnyPromptSaving || !form.values.custom_prompt}
>
Clear
</Button>
<Button
size="xs"
type="submit"
disabled={isAnyPromptSaving || !form.dirty}
>
{isSavingUserPrompt && <Spinner loading className="h-4 w-4" />}
Save
</Button>
</>
))}
</div>
{isSaveUserPromptError && (
<p className="m-0 text-xs text-content-destructive">
@@ -4,8 +4,14 @@ import { useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { AdminBadge } from "./AdminBadge";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
@@ -44,6 +50,7 @@ export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
const [retentionToggled, setRetentionToggled] = useState<boolean | null>(
null,
);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const serverRetentionDays = retentionDaysData?.retention_days ?? 30;
const isRetentionEnabled = retentionToggled ?? serverRetentionDays > 0;
@@ -57,6 +64,7 @@ export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
{ retention_days: values.retention_days },
{
onSuccess: () => {
showSavedState();
setRetentionToggled(null);
helpers.resetForm();
},
@@ -96,18 +104,14 @@ export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
};
return (
<form className="space-y-2" onSubmit={form.handleSubmit}>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Conversation Retention Period
</h3>
<AdminBadge />
</div>
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Archived conversations and orphaned files older than this are
automatically deleted.
</p>
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Conversation Retention Period
</h3>
<AdminBadge />
</div>
<Switch
checked={isRetentionEnabled}
onCheckedChange={handleToggleRetention}
@@ -115,9 +119,13 @@ export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
disabled={isSavingRetentionDays || isRetentionDaysLoading}
/>
</div>
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Archived conversations and orphaned files older than this are
automatically deleted.
</p>
{isRetentionEnabled && (
<>
<input
<Input
type="number"
name="retention_days"
min={1}
@@ -127,25 +135,33 @@ export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
value={form.values.retention_days}
onChange={form.handleChange}
disabled={isSavingRetentionDays || isRetentionDaysLoading}
className="w-full rounded-lg border border-border bg-surface-primary px-4 py-2 text-[13px] text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30"
className="w-full"
/>
{form.errors.retention_days && form.touched.retention_days && (
<p className="m-0 text-xs text-content-destructive">
{form.errors.retention_days}
</p>
)}
<div className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={
isSavingRetentionDays ||
!form.dirty ||
Boolean(form.errors.retention_days)
}
>
Save
</Button>
<div className="mt-2 flex min-h-6 justify-end">
{(form.dirty || isSavedVisible || isSavingRetentionDays) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="xs"
type="submit"
disabled={
isSavingRetentionDays ||
!form.dirty ||
Boolean(form.errors.retention_days)
}
>
{isSavingRetentionDays && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
))}
</div>
</>
)}
@@ -28,18 +28,15 @@ export const SectionHeader: FC<SectionHeaderProps> = ({
: "m-0 mt-0.5 text-sm text-content-secondary";
return (
<>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex w-full items-center gap-2">
<Heading className={headingClass}>{label}</Heading>
{badge}
</div>
{description && <p className={descriptionClass}>{description}</p>}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex w-full items-center gap-2">
<Heading className={headingClass}>{label}</Heading>
{badge}
</div>
{action}
{description && <p className={descriptionClass}>{description}</p>}
</div>
<hr className="my-4 border-0 border-t border-solid border-border" />
</>
{action}
</div>
);
};
@@ -5,10 +5,15 @@ import TextareaAutosize from "react-textarea-autosize";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { AdminBadge } from "./AdminBadge";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
import { TextPreviewDialog } from "./TextPreviewDialog";
interface MutationCallbacks {
@@ -22,6 +27,7 @@ interface SystemInstructionsSettingsProps {
req: TypesGen.UpdateChatSystemPromptRequest,
options?: MutationCallbacks,
) => void;
isSavingSystemPrompt: boolean;
isSaveSystemPromptError: boolean;
isAnyPromptSaving: boolean;
}
@@ -31,6 +37,7 @@ export const SystemInstructionsSettings: FC<
> = ({
systemPromptData,
onSaveSystemPrompt,
isSavingSystemPrompt,
isSaveSystemPromptError,
isAnyPromptSaving,
}) => {
@@ -38,6 +45,7 @@ export const SystemInstructionsSettings: FC<
useState(false);
const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] =
useState(false);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const hasLoadedSystemPrompt = systemPromptData !== undefined;
const defaultSystemPrompt = systemPromptData?.default_system_prompt ?? "";
@@ -52,6 +60,7 @@ export const SystemInstructionsSettings: FC<
onSubmit: (values, { resetForm }) => {
onSaveSystemPrompt(values, {
onSuccess: () => {
showSavedState();
resetForm();
},
});
@@ -65,9 +74,9 @@ export const SystemInstructionsSettings: FC<
return (
<>
<form className="space-y-2" onSubmit={form.handleSubmit}>
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
<h3 className="m-0 text-sm font-semibold text-content-primary">
System Instructions
</h3>
<AdminBadge />
@@ -102,7 +111,7 @@ export const SystemInstructionsSettings: FC<
</p>
<TextareaAutosize
className={cn(
"max-h-[240px] w-full resize-none rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30",
"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-none focus:ring-2 focus:ring-content-link/30",
isSystemPromptOverflowing &&
"overflow-y-auto [scrollbar-width:thin]",
)}
@@ -125,25 +134,38 @@ export const SystemInstructionsSettings: FC<
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => form.setFieldValue("system_prompt", "")}
disabled={isSystemPromptDisabled || !form.values.system_prompt}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={
isSystemPromptDisabled || !(form.dirty && hasLoadedSystemPrompt)
}
>
Save
</Button>
<div className="mt-2 flex min-h-6 justify-end gap-2">
{(form.dirty || isSavedVisible || isSavingSystemPrompt) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<>
<Button
size="xs"
variant="outline"
type="button"
onClick={() => form.setFieldValue("system_prompt", "")}
disabled={
isSystemPromptDisabled || !form.values.system_prompt
}
>
Clear
</Button>
<Button
size="xs"
type="submit"
disabled={
isSystemPromptDisabled ||
!(form.dirty && hasLoadedSystemPrompt)
}
>
{isSavingSystemPrompt && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
</>
))}
</div>
{isSaveSystemPromptError && (
<p className="m-0 text-xs text-content-destructive">
@@ -0,0 +1,44 @@
import { CheckIcon } from "lucide-react";
import type { FC } from "react";
import { useEffect, useRef, useState } from "react";
export const useTemporarySavedState = (
durationMs = 2500,
): {
isSavedVisible: boolean;
showSavedState: () => void;
} => {
const [isSavedVisible, setIsSavedVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const showSavedState = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsSavedVisible(true);
timeoutRef.current = setTimeout(() => {
setIsSavedVisible(false);
timeoutRef.current = null;
}, durationMs);
};
return { isSavedVisible, showSavedState };
};
export const TemporarySavedState: FC = () => (
<div
aria-live="polite"
className="inline-flex min-w-8 min-h-6 shrink-0 items-center justify-center gap-1 rounded-md border border-border-success bg-surface-success px-2 font-sans text-2xs font-medium whitespace-nowrap text-content-success"
>
<CheckIcon className="size-3.5" />
<span>Saved</span>
</div>
);
@@ -70,7 +70,7 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("spinbutton", {
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});
@@ -96,10 +96,10 @@ export const Default: Story = {
export const SaveAll: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("spinbutton", {
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});
const claudeInput = await canvas.findByRole("spinbutton", {
const claudeInput = await canvas.findByRole("textbox", {
name: /Claude Sonnet compaction threshold/i,
});
@@ -129,15 +129,15 @@ export const WithOverrides: Story = {
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("spinbutton", {
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});
const claudeInput = await canvas.findByRole("spinbutton", {
const claudeInput = await canvas.findByRole("textbox", {
name: /Claude Sonnet compaction threshold/i,
});
expect(gpt4oInput).toHaveValue(90);
expect(claudeInput).toHaveValue(50);
expect(gpt4oInput).toHaveValue("90");
expect(claudeInput).toHaveValue("50");
// Reset buttons should be visible for both overridden models
const resetButtons = canvas.getAllByRole("button", {
@@ -155,7 +155,7 @@ export const WithOverrides: Story = {
export const CancelChanges: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("spinbutton", {
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});
@@ -171,7 +171,7 @@ export const CancelChanges: Story = {
});
// Input should be cleared back to empty (no override)
expect(gpt4oInput).toHaveValue(null);
expect(gpt4oInput).toHaveValue("");
},
};
@@ -179,11 +179,11 @@ export const InvalidDraftShowsFooter: Story = {
name: "Invalid Draft Shows Footer",
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("spinbutton", {
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});
// Type an out-of-range value (number inputs reject non-numeric chars)
// Type an out-of-range value.
await userEvent.type(gpt4oInput, "150");
// Input should be marked invalid
@@ -205,7 +205,7 @@ export const DisableCompactionWarning: Story = {
name: "100% Disable Compaction Warning",
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("spinbutton", {
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});
@@ -239,10 +239,10 @@ export const PartialSaveFailure: Story = {
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("spinbutton", {
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});
const claudeInput = await canvas.findByRole("spinbutton", {
const claudeInput = await canvas.findByRole("textbox", {
name: /Claude Sonnet compaction threshold/i,
});
@@ -2,6 +2,7 @@ import { RotateCcwIcon } from "lucide-react";
import { type FC, useState } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
@@ -20,6 +21,11 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { ProviderIcon } from "./ChatModelAdminPanel/ProviderIcon";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface UserCompactionThresholdSettingsProps {
modelConfigs: readonly TypesGen.ChatModelConfig[];
@@ -49,6 +55,18 @@ const parseThresholdDraft = (value: string): number | null => {
return parsedValue;
};
const ContextCompactionHeader: FC = () => (
<div className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Context Compaction
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Control when conversation context is automatically summarized for each
model. Setting 100% means the conversation will never auto-compact.
</p>
</div>
);
export const UserCompactionThresholdSettings: FC<
UserCompactionThresholdSettingsProps
> = ({
@@ -64,6 +82,7 @@ export const UserCompactionThresholdSettings: FC<
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [rowErrors, setRowErrors] = useState<Record<string, string>>({});
const [pendingModels, setPendingModels] = useState<Set<string>>(new Set());
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const enabledModelConfigs = modelConfigs.filter((config) => config.enabled);
const overridesByModelID = new Map(
@@ -149,6 +168,7 @@ export const UserCompactionThresholdSettings: FC<
.then(() => {
clearDraft(modelConfigId);
clearRowError(modelConfigId);
return true;
})
.catch((error: unknown) => {
setRowErrors((currentErrors) => ({
@@ -158,12 +178,17 @@ export const UserCompactionThresholdSettings: FC<
"Failed to save compaction threshold.",
),
}));
return false;
})
.finally(() => {
removePending(modelConfigId);
});
});
void Promise.allSettled(saves);
void Promise.all(saves).then((results) => {
if (results.length > 0 && results.every(Boolean)) {
showSavedState();
}
});
};
const handleCancelAll = () => {
@@ -174,17 +199,13 @@ export const UserCompactionThresholdSettings: FC<
const hasAnyPending = pendingModels.size > 0;
const hasAnyErrors = Object.keys(rowErrors).length > 0;
const hasAnyDrafts = Object.keys(drafts).length > 0;
const shouldShowActions =
hasAnyDrafts || hasAnyErrors || hasAnyPending || dirtyRows.length > 0;
if (isThresholdsLoading) {
return (
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Context Compaction
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Control when conversation context is automatically summarized for each
model. Setting 100% means the conversation will never auto-compact.
</p>
<div className="flex flex-col gap-2">
<ContextCompactionHeader />
<div className="flex items-center gap-2 text-sm text-content-secondary">
<Spinner loading className="h-4 w-4" />
Loading thresholds...
@@ -195,14 +216,8 @@ export const UserCompactionThresholdSettings: FC<
if (thresholdsError != null) {
return (
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Context Compaction
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Control when conversation context is automatically summarized for each
model. Setting 100% means the conversation will never auto-compact.
</p>
<div className="flex flex-col gap-2">
<ContextCompactionHeader />
<p className="m-0 text-xs text-content-destructive">
{getErrorMessage(
thresholdsError,
@@ -214,14 +229,8 @@ export const UserCompactionThresholdSettings: FC<
}
return (
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Context Compaction
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Control when conversation context is automatically summarized for each
model. Setting 100% means the conversation will never auto-compact.
</p>
<div className="flex flex-col gap-3">
<ContextCompactionHeader />
{isLoadingModelConfigs ? (
<div className="flex items-center gap-2 text-sm text-content-secondary">
<Spinner loading className="h-4 w-4" />
@@ -243,7 +252,7 @@ export const UserCompactionThresholdSettings: FC<
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-content-secondary">Model</TableHead>
<TableHead className="w-0 whitespace-nowrap">Default</TableHead>
<TableHead className="w-0 whitespace-nowrap">Threshold</TableHead>
</TableRow>
@@ -270,8 +279,14 @@ export const UserCompactionThresholdSettings: FC<
return (
<TableRow key={modelConfig.id}>
<TableCell className="text-[13px] font-medium text-content-primary">
{modelName}
<TableCell className="text-sm font-medium text-content-primary">
<Badge size="sm" variant="default" className="w-fit">
<ProviderIcon
provider={modelConfig.provider}
className="h-4 w-4"
/>
{modelName}
</Badge>
{rowError && (
<p
aria-live="polite"
@@ -288,31 +303,40 @@ export const UserCompactionThresholdSettings: FC<
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Input
aria-label={`${modelName} compaction threshold`}
aria-invalid={isInvalid || undefined}
type="number"
min={0}
max={100}
inputMode="numeric"
className={cn(
"h-7 w-16 px-2 text-xs tabular-nums",
isInvalid &&
"border-content-destructive focus:ring-content-destructive/30",
)}
value={draftValue}
placeholder={String(
modelConfig.compression_threshold,
)}
onChange={(event) => {
setDrafts((currentDrafts) => ({
...currentDrafts,
[modelConfig.id]: event.target.value,
}));
clearRowError(modelConfig.id);
}}
disabled={isThisModelMutating}
/>
<div className="relative">
<Input
aria-label={`${modelName} compaction threshold`}
aria-invalid={isInvalid || undefined}
type="text"
min={0}
max={100}
maxLength={3}
inputMode="numeric"
className={cn(
"h-7 w-16 px-2 pr-5 text-xs tabular-nums",
isInvalid &&
"border-content-destructive focus:ring-content-destructive/30",
)}
value={draftValue}
placeholder={String(
modelConfig.compression_threshold,
)}
onChange={(event) => {
setDrafts((currentDrafts) => ({
...currentDrafts,
[modelConfig.id]: event.target.value,
}));
clearRowError(modelConfig.id);
}}
disabled={isThisModelMutating}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-xs text-content-secondary"
>
%
</span>
</div>
</TooltipTrigger>
{(isInvalid || isDraftDisablingCompaction) && (
<TooltipContent>
@@ -322,7 +346,6 @@ export const UserCompactionThresholdSettings: FC<
</TooltipContent>
)}
</Tooltip>
<span className="text-xs text-content-secondary">%</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -367,37 +390,46 @@ export const UserCompactionThresholdSettings: FC<
);
})}
</TableBody>
{(dirtyRows.length > 0 || hasAnyErrors || hasAnyDrafts) && (
<TableFooter className="bg-transparent">
<TableRow className="border-0">
<TableCell colSpan={3} className="border-0 p-0">
<div className="flex items-center justify-end gap-2 px-3 py-1.5">
<Button
size="sm"
variant="outline"
type="button"
onClick={handleCancelAll}
disabled={hasAnyPending}
>
Cancel
</Button>
{dirtyRows.length > 0 && (
<Button
size="sm"
type="button"
disabled={hasAnyPending}
onClick={handleSaveAll}
>
{hasAnyPending
? "Saving..."
: `Save ${dirtyRows.length} ${dirtyRows.length === 1 ? "change" : "changes"}`}
</Button>
)}
</div>
</TableCell>
</TableRow>
</TableFooter>
)}
<TableFooter className="bg-transparent">
<TableRow className="border-0">
<TableCell colSpan={3} className="border-0 p-0">
<div className="mt-2 flex h-6 items-center justify-end gap-2 px-3">
{isSavedVisible ? (
<TemporarySavedState />
) : (
shouldShowActions && (
<>
<Button
size="xs"
variant="outline"
type="button"
onClick={handleCancelAll}
disabled={hasAnyPending}
>
Cancel
</Button>
{dirtyRows.length > 0 && (
<Button
size="xs"
type="button"
disabled={hasAnyPending}
onClick={handleSaveAll}
>
{hasAnyPending && (
<Spinner loading className="h-4 w-4" />
)}
{hasAnyPending
? "Saving..."
: `Save ${dirtyRows.length} ${dirtyRows.length === 1 ? "change" : "changes"}`}
</Button>
)}
</>
)
)}
</div>
</TableCell>
</TableRow>
</TableFooter>
</Table>
)}
</div>
@@ -1,5 +1,7 @@
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Link } from "#/components/Link/Link";
import { Switch } from "#/components/Switch/Switch";
import { AdminBadge } from "./AdminBadge";
@@ -28,32 +30,17 @@ export const VirtualDesktopSettings: FC<VirtualDesktopSettingsProps> = ({
const desktopEnabled = desktopEnabledData?.enable_desktop ?? false;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Virtual Desktop
</h3>
<AdminBadge />
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
<p className="m-0">
Allow agents to use a virtual, graphical desktop within workspaces.
Requires the{" "}
<Link
href="https://registry.coder.com/modules/coder/portabledesktop"
target="_blank"
size="sm"
>
portabledesktop module
</Link>{" "}
to be installed in the workspace and the Anthropic provider to be
configured.
</p>
<p className="mt-2 mb-0 font-semibold text-content-secondary">
Warning: This is a work-in-progress feature, and you're likely to
encounter bugs if you enable it.
</p>
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Virtual Desktop
</h3>
<AdminBadge />
<Badge size="sm" variant="warning" className="cursor-default">
<TriangleAlertIcon className="h-3 w-3" />
Experimental feature
</Badge>
</div>
<Switch
checked={desktopEnabled}
@@ -64,6 +51,21 @@ export const VirtualDesktopSettings: FC<VirtualDesktopSettingsProps> = ({
disabled={isSavingDesktopEnabled}
/>
</div>
<div className="m-0 flex-1 text-xs text-content-secondary">
<p className="m-0">
Allow agents to use a virtual, graphical desktop within workspaces.
Requires the{" "}
<Link
href="https://registry.coder.com/modules/coder/portabledesktop"
target="_blank"
size="sm"
>
portabledesktop module
</Link>{" "}
to be installed in the workspace and the Anthropic provider to be
configured.
</p>
</div>
{isSaveDesktopEnabledError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save desktop setting.
@@ -4,9 +4,14 @@ import { useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { AdminBadge } from "./AdminBadge";
import { DurationField } from "./DurationField/DurationField";
import {
TemporarySavedState,
useTemporarySavedState,
} from "./TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
@@ -37,6 +42,7 @@ export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
}) => {
// ── Toggle state (fires immediate mutations, not a form submit) ──
const [autostopToggled, setAutostopToggled] = useState<boolean | null>(null);
const { isSavedVisible, showSavedState } = useTemporarySavedState();
// ── Derived state ──
const serverTTLMs = workspaceTTLData?.workspace_ttl_ms ?? 0;
@@ -63,6 +69,7 @@ export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
{ workspace_ttl_ms: values.workspace_ttl_ms },
{
onSuccess: () => {
showSavedState();
setAutostopToggled(null);
helpers.resetForm();
},
@@ -111,19 +118,14 @@ export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
const fieldError = form.errors.workspace_ttl_ms;
return (
<form className="space-y-2" onSubmit={form.handleSubmit}>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Workspace Autostop Fallback
</h3>
<AdminBadge />
</div>
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Set a default autostop for agent-created workspaces that don't have
one defined in their template. Template-defined autostop rules always
take precedence. Active conversations will extend the stop time.
</p>
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Workspace Autostop Fallback
</h3>
<AdminBadge />
</div>
<Switch
checked={isAutostopEnabled}
onCheckedChange={handleToggleAutostop}
@@ -131,6 +133,11 @@ export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
/>
</div>
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Set a default autostop for agent-created workspaces that don't have one
defined in their template. Template-defined autostop rules always take
precedence. Active conversations will extend the stop time.
</p>
{isAutostopEnabled && (
<DurationField
valueMs={form.values.workspace_ttl_ms}
@@ -142,16 +149,24 @@ export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
/>
)}
{isAutostopEnabled && (
<div className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={
isSavingWorkspaceTTL || !form.dirty || Boolean(fieldError)
}
>
Save
</Button>
<div className="mt-2 flex min-h-6 justify-end">
{(form.dirty || isSavedVisible || isSavingWorkspaceTTL) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="xs"
type="submit"
disabled={
isSavingWorkspaceTTL || !form.dirty || Boolean(fieldError)
}
>
{isSavingWorkspaceTTL && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
))}
</div>
)}
{isSaveWorkspaceTTLError && (