mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user