refactor: decompose AgentSettingsBehaviorPageView + remove kyleosophy (#24141)
- Remove Kyleosophy alternative completion chimes (keeps original chime
intact)
- Extract 5 sub-components from the 717-line god component:
- `PersonalInstructionsSettings` — user prompt textarea form
- `SystemInstructionsSettings` — admin system prompt + TextPreviewDialog
- `VirtualDesktopSettings` — admin desktop toggle
- `WorkspaceAutostopSettings` — admin autostop toggle + duration form
- `RetentionPeriodSettings` — admin retention toggle + number input
- Parent is now a ~160-line layout shell
- `isAnyPromptSaving` coupling preserved via prop
- Add `docs/plans/` to `.gitignore`
> 🤖 Written by a Coder Agent. Reviewed by a human.
This commit is contained in:
@@ -103,3 +103,6 @@ PLAN.md
|
||||
|
||||
# Ignore any dev licenses
|
||||
license.txt
|
||||
-e
|
||||
# Agent planning documents (local working files).
|
||||
docs/plans/
|
||||
|
||||
@@ -460,33 +460,3 @@ export const NoWarningForCleanPrompt: Story = {
|
||||
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
// ── Kyleosophy ─────────────────────────────────────────────────
|
||||
|
||||
export const KylesophyToggle: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
localStorage.removeItem("agents.kyleosophy");
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText("Kyleosophy");
|
||||
await canvas.findByText(/Replace the standard completion chime/i);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Enable Kyleosophy",
|
||||
});
|
||||
expect(toggle).not.toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const TogglesKyleosophy: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
localStorage.removeItem("agents.kyleosophy");
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("switch", {
|
||||
name: "Enable Kyleosophy",
|
||||
});
|
||||
|
||||
await userEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("agents.kyleosophy")).toBe("true");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import type { FC, FormEvent } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
|
||||
import { AdminBadge } from "./components/AdminBadge";
|
||||
import { DurationField } from "./components/DurationField/DurationField";
|
||||
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
|
||||
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
|
||||
import { SectionHeader } from "./components/SectionHeader";
|
||||
import { TextPreviewDialog } from "./components/TextPreviewDialog";
|
||||
import { SystemInstructionsSettings } from "./components/SystemInstructionsSettings";
|
||||
import { UserCompactionThresholdSettings } from "./components/UserCompactionThresholdSettings";
|
||||
import {
|
||||
getKylesophyEnabled,
|
||||
isKylesophyForced,
|
||||
setKylesophyEnabled,
|
||||
} from "./utils/chime";
|
||||
|
||||
const textareaMaxHeight = 240;
|
||||
const textareaBaseClassName =
|
||||
"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";
|
||||
const textareaOverflowClassName = "overflow-y-auto [scrollbar-width:thin]";
|
||||
import { VirtualDesktopSettings } from "./components/VirtualDesktopSettings";
|
||||
import { WorkspaceAutostopSettings } from "./components/WorkspaceAutostopSettings";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
@@ -130,193 +114,7 @@ export const AgentSettingsBehaviorPageView: FC<
|
||||
isSavingRetentionDays,
|
||||
isSaveRetentionDaysError,
|
||||
}) => {
|
||||
// ── Local form state ──
|
||||
const [localEdit, setLocalEdit] = useState<string | null>(null);
|
||||
const [localIncludeDefault, setLocalIncludeDefault] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [showDefaultPromptPreview, setShowDefaultPromptPreview] =
|
||||
useState(false);
|
||||
const [localUserEdit, setLocalUserEdit] = useState<string | null>(null);
|
||||
const [localTTLMs, setLocalTTLMs] = useState<number | null>(null);
|
||||
const [autostopToggled, setAutostopToggled] = useState<boolean | null>(null);
|
||||
const [localRetentionDays, setLocalRetentionDays] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [retentionToggled, setRetentionToggled] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Overflow states are pure UI — managed locally in the view.
|
||||
const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false);
|
||||
const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] =
|
||||
useState(false);
|
||||
const kylesophyForced = isKylesophyForced();
|
||||
const [kylesophyEnabled, setLocalKylesophy] = useState(getKylesophyEnabled);
|
||||
|
||||
// ── Derived state ──
|
||||
const hasLoadedSystemPrompt = systemPromptData !== undefined;
|
||||
const serverPrompt = systemPromptData?.system_prompt ?? "";
|
||||
const serverIncludeDefault = systemPromptData?.include_default_system_prompt;
|
||||
const defaultSystemPrompt = systemPromptData?.default_system_prompt ?? "";
|
||||
const systemPromptDraft = localEdit ?? serverPrompt;
|
||||
const includeDefaultDraft =
|
||||
localIncludeDefault ?? serverIncludeDefault ?? false;
|
||||
|
||||
const serverUserPrompt = userPromptData?.custom_prompt ?? "";
|
||||
const userPromptDraft = localUserEdit ?? serverUserPrompt;
|
||||
|
||||
const systemInvisibleCharCount = useMemo(
|
||||
() => countInvisibleCharacters(systemPromptDraft),
|
||||
[systemPromptDraft],
|
||||
);
|
||||
const userInvisibleCharCount = useMemo(
|
||||
() => countInvisibleCharacters(userPromptDraft),
|
||||
[userPromptDraft],
|
||||
);
|
||||
|
||||
const isPromptSaving = isSavingSystemPrompt || isSavingUserPrompt;
|
||||
const isSystemPromptDirty =
|
||||
hasLoadedSystemPrompt &&
|
||||
((localEdit !== null && localEdit !== serverPrompt) ||
|
||||
(localIncludeDefault !== null &&
|
||||
localIncludeDefault !== serverIncludeDefault));
|
||||
const isSystemPromptDisabled = isPromptSaving || !hasLoadedSystemPrompt;
|
||||
const isUserPromptDirty =
|
||||
localUserEdit !== null && localUserEdit !== serverUserPrompt;
|
||||
const desktopEnabled = desktopEnabledData?.enable_desktop ?? false;
|
||||
const serverTTLMs = workspaceTTLData?.workspace_ttl_ms ?? 0;
|
||||
const ttlMs = localTTLMs ?? serverTTLMs;
|
||||
const isAutostopEnabled = autostopToggled ?? serverTTLMs > 0;
|
||||
const isTTLDirty = localTTLMs !== null && localTTLMs !== serverTTLMs;
|
||||
const maxTTLMs = 30 * 24 * 60 * 60_000; // 30 days
|
||||
const isTTLOverMax = ttlMs > maxTTLMs;
|
||||
const isTTLZero = isAutostopEnabled && ttlMs === 0;
|
||||
|
||||
// ── Retention days derived state ──
|
||||
const serverRetentionDays = retentionDaysData?.retention_days ?? 30;
|
||||
const retentionDays = localRetentionDays ?? serverRetentionDays;
|
||||
const isRetentionEnabled = retentionToggled ?? serverRetentionDays > 0;
|
||||
const isRetentionDaysDirty =
|
||||
localRetentionDays !== null && localRetentionDays !== serverRetentionDays;
|
||||
const isRetentionDaysNegative = isRetentionEnabled && retentionDays < 0;
|
||||
// Keep in sync with retentionDaysMaximum in coderd/exp_chats.go.
|
||||
const retentionDaysMaximum = 3650;
|
||||
const isRetentionDaysOverMax = retentionDays > retentionDaysMaximum;
|
||||
const isRetentionDaysZero = isRetentionEnabled && retentionDays === 0;
|
||||
|
||||
// ── Event handlers ──
|
||||
const handleSaveSystemPrompt = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!hasLoadedSystemPrompt || !isSystemPromptDirty) return;
|
||||
onSaveSystemPrompt(
|
||||
{
|
||||
system_prompt: systemPromptDraft,
|
||||
include_default_system_prompt: includeDefaultDraft,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLocalEdit(null);
|
||||
setLocalIncludeDefault(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveUserPrompt = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isUserPromptDirty) return;
|
||||
onSaveUserPrompt(
|
||||
{ custom_prompt: userPromptDraft },
|
||||
{ onSuccess: () => setLocalUserEdit(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const resetAutostopState = () => {
|
||||
setLocalTTLMs(null);
|
||||
setAutostopToggled(null);
|
||||
};
|
||||
|
||||
const handleToggleAutostop = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// Defensive: restore server value if query cache is
|
||||
// stale; otherwise default to 1 hour.
|
||||
const defaultTTL = serverTTLMs > 0 ? serverTTLMs : 3_600_000;
|
||||
setAutostopToggled(true);
|
||||
setLocalTTLMs(defaultTTL);
|
||||
onSaveWorkspaceTTL(
|
||||
{ workspace_ttl_ms: defaultTTL },
|
||||
{ onSuccess: resetAutostopState, onError: resetAutostopState },
|
||||
);
|
||||
} else {
|
||||
setAutostopToggled(false);
|
||||
setLocalTTLMs(0);
|
||||
onSaveWorkspaceTTL(
|
||||
{ workspace_ttl_ms: 0 },
|
||||
{ onSuccess: resetAutostopState, onError: resetAutostopState },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChatWorkspaceTTL = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isTTLDirty || isSavingWorkspaceTTL) return;
|
||||
onSaveWorkspaceTTL(
|
||||
{ workspace_ttl_ms: localTTLMs ?? 0 },
|
||||
{
|
||||
onSuccess: resetAutostopState,
|
||||
onError: () => setAutostopToggled(null),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleTTLChange = (value: number) => {
|
||||
setLocalTTLMs(value);
|
||||
// Latch the toggle open while the user is editing
|
||||
// so a background refetch cannot unmount the field.
|
||||
if (autostopToggled === null) {
|
||||
setAutostopToggled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const resetRetentionState = () => {
|
||||
setLocalRetentionDays(null);
|
||||
setRetentionToggled(null);
|
||||
};
|
||||
|
||||
const handleToggleRetention = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setRetentionToggled(true);
|
||||
setLocalRetentionDays(serverRetentionDays > 0 ? serverRetentionDays : 30);
|
||||
onSaveRetentionDays(
|
||||
{ retention_days: serverRetentionDays > 0 ? serverRetentionDays : 30 },
|
||||
{ onSuccess: resetRetentionState, onError: resetRetentionState },
|
||||
);
|
||||
} else {
|
||||
setRetentionToggled(false);
|
||||
setLocalRetentionDays(0);
|
||||
onSaveRetentionDays(
|
||||
{ retention_days: 0 },
|
||||
{ onSuccess: resetRetentionState, onError: resetRetentionState },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetentionDaysChange = (value: number) => {
|
||||
setLocalRetentionDays(value);
|
||||
if (retentionToggled === null) {
|
||||
setRetentionToggled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRetentionDays = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isRetentionDaysDirty || isSavingRetentionDays) return;
|
||||
onSaveRetentionDays(
|
||||
{ retention_days: localRetentionDays ?? 30 },
|
||||
{ onSuccess: resetRetentionState },
|
||||
);
|
||||
};
|
||||
const isAnyPromptSaving = isSavingSystemPrompt || isSavingUserPrompt;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -324,64 +122,14 @@ export const AgentSettingsBehaviorPageView: FC<
|
||||
label="Behavior"
|
||||
description="Custom instructions that shape how the agent responds in your conversations."
|
||||
/>
|
||||
{/* ── Personal prompt (always visible) ── */}
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => void handleSaveUserPrompt(event)}
|
||||
>
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Personal Instructions
|
||||
</h3>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
Applied to all your conversations. Only visible to you.
|
||||
</p>
|
||||
<TextareaAutosize
|
||||
className={cn(
|
||||
textareaBaseClassName,
|
||||
isUserPromptOverflowing && textareaOverflowClassName,
|
||||
)}
|
||||
placeholder="Additional behavior, style, and tone preferences"
|
||||
value={userPromptDraft}
|
||||
onChange={(event) => setLocalUserEdit(event.target.value)}
|
||||
onHeightChange={(height) =>
|
||||
setIsUserPromptOverflowing(height >= textareaMaxHeight)
|
||||
}
|
||||
disabled={isPromptSaving}
|
||||
minRows={1}
|
||||
/>
|
||||
{userInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
This text contains {userInvisibleCharCount} invisible Unicode{" "}
|
||||
{userInvisibleCharCount !== 1 ? "characters" : "character"} that
|
||||
could hide content. These will be stripped on save.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => setLocalUserEdit("")}
|
||||
disabled={isPromptSaving || !userPromptDraft}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isPromptSaving || !isUserPromptDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{isSaveUserPromptError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save personal instructions.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<PersonalInstructionsSettings
|
||||
userPromptData={userPromptData}
|
||||
onSaveUserPrompt={onSaveUserPrompt}
|
||||
isSavingUserPrompt={isSavingUserPrompt}
|
||||
isSaveUserPromptError={isSaveUserPromptError}
|
||||
isAnyPromptSaving={isAnyPromptSaving}
|
||||
/>
|
||||
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<UserCompactionThresholdSettings
|
||||
@@ -394,322 +142,45 @@ export const AgentSettingsBehaviorPageView: FC<
|
||||
onSaveThreshold={onSaveThreshold}
|
||||
onResetThreshold={onResetThreshold}
|
||||
/>
|
||||
{/* ── Admin system prompt (admin only) ── */}
|
||||
|
||||
{/* ── Admin-only settings ── */}
|
||||
{canSetSystemPrompt && (
|
||||
<>
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => void handleSaveSystemPrompt(event)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
System Instructions
|
||||
</h3>
|
||||
<AdminBadge />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-content-primary">
|
||||
<span>Include Coder Agents default system prompt</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() => setShowDefaultPromptPreview(true)}
|
||||
disabled={!hasLoadedSystemPrompt}
|
||||
className="min-w-0 px-0 text-content-link hover:text-content-link"
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
<Switch
|
||||
checked={includeDefaultDraft}
|
||||
onCheckedChange={setLocalIncludeDefault}
|
||||
aria-label="Include Coder Agents default system prompt"
|
||||
disabled={isSystemPromptDisabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
{includeDefaultDraft
|
||||
? "The built-in Coder Agents prompt is prepended. Additional instructions below are appended."
|
||||
: "Only the additional instructions below are used. When empty, no deployment-wide system prompt is sent."}
|
||||
</p>
|
||||
<TextareaAutosize
|
||||
className={cn(
|
||||
textareaBaseClassName,
|
||||
isSystemPromptOverflowing && textareaOverflowClassName,
|
||||
)}
|
||||
placeholder="Additional instructions for all users"
|
||||
value={systemPromptDraft}
|
||||
onChange={(event) => setLocalEdit(event.target.value)}
|
||||
onHeightChange={(height) =>
|
||||
setIsSystemPromptOverflowing(height >= textareaMaxHeight)
|
||||
}
|
||||
disabled={isSystemPromptDisabled}
|
||||
minRows={1}
|
||||
/>
|
||||
{systemInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
This text contains {systemInvisibleCharCount} invisible
|
||||
Unicode{" "}
|
||||
{systemInvisibleCharCount !== 1 ? "characters" : "character"}{" "}
|
||||
that could hide content. These will be stripped on save.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => setLocalEdit("")}
|
||||
disabled={isSystemPromptDisabled || !systemPromptDraft}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isSystemPromptDisabled || !isSystemPromptDirty}
|
||||
>
|
||||
Save
|
||||
</Button>{" "}
|
||||
</div>
|
||||
{isSaveSystemPromptError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save system prompt.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<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 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>
|
||||
<Switch
|
||||
checked={desktopEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveDesktopEnabled({ enable_desktop: checked })
|
||||
}
|
||||
aria-label="Enable"
|
||||
disabled={isSavingDesktopEnabled}
|
||||
/>
|
||||
</div>
|
||||
{isSaveDesktopEnabledError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save desktop setting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => void handleSaveChatWorkspaceTTL(event)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Workspace Autostop Fallback
|
||||
</h3>
|
||||
<AdminBadge />
|
||||
</div>
|
||||
<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>
|
||||
<Switch
|
||||
checked={isAutostopEnabled}
|
||||
onCheckedChange={handleToggleAutostop}
|
||||
aria-label="Enable default autostop"
|
||||
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
|
||||
/>{" "}
|
||||
</div>
|
||||
{isAutostopEnabled && (
|
||||
<DurationField
|
||||
valueMs={ttlMs}
|
||||
onChange={handleTTLChange}
|
||||
label="Autostop Fallback"
|
||||
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
|
||||
error={isTTLOverMax || isTTLZero}
|
||||
helperText={
|
||||
isTTLZero
|
||||
? "Duration must be greater than zero."
|
||||
: isTTLOverMax
|
||||
? "Must not exceed 30 days (720 hours)."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isAutostopEnabled && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={
|
||||
isSavingWorkspaceTTL ||
|
||||
!isTTLDirty ||
|
||||
isTTLOverMax ||
|
||||
isTTLZero
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isSaveWorkspaceTTLError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save autostop setting.
|
||||
</p>
|
||||
)}
|
||||
{isWorkspaceTTLLoadError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load autostop setting.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => void handleSaveRetentionDays(event)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Conversation Retention Period
|
||||
</h3>
|
||||
<AdminBadge />
|
||||
</div>
|
||||
<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>
|
||||
<Switch
|
||||
checked={isRetentionEnabled}
|
||||
onCheckedChange={handleToggleRetention}
|
||||
aria-label="Enable conversation retention"
|
||||
disabled={isSavingRetentionDays || isRetentionDaysLoading}
|
||||
/>
|
||||
</div>
|
||||
{isRetentionEnabled && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
step={1}
|
||||
aria-label="Conversation retention period in days"
|
||||
value={retentionDays}
|
||||
onChange={(event) =>
|
||||
handleRetentionDaysChange(
|
||||
Number.parseInt(event.target.value, 10) || 0,
|
||||
)
|
||||
}
|
||||
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"
|
||||
/>
|
||||
{isRetentionDaysZero && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Retention period must be at least 1 day.
|
||||
</p>
|
||||
)}
|
||||
{isRetentionDaysNegative && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Retention days cannot be negative.
|
||||
</p>
|
||||
)}
|
||||
{isRetentionDaysOverMax && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Must not exceed {retentionDaysMaximum} days (~10 years).
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={
|
||||
isSavingRetentionDays ||
|
||||
!isRetentionDaysDirty ||
|
||||
isRetentionDaysNegative ||
|
||||
isRetentionDaysOverMax ||
|
||||
isRetentionDaysZero
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isSaveRetentionDaysError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save retention setting.
|
||||
</p>
|
||||
)}
|
||||
{isRetentionDaysLoadError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load retention setting.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
{/* ── Kyleosophy toggle (always visible) ── */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Kyleosophy
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
|
||||
Replace the standard completion chime. IYKYK.
|
||||
{kylesophyForced && (
|
||||
<span className="ml-1 font-semibold">
|
||||
Kyleosophy is mandatory on <code>dev.coder.com</code>.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Switch
|
||||
checked={kylesophyEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setKylesophyEnabled(checked);
|
||||
setLocalKylesophy(checked);
|
||||
}}
|
||||
aria-label="Enable Kyleosophy"
|
||||
disabled={kylesophyForced}
|
||||
<SystemInstructionsSettings
|
||||
systemPromptData={systemPromptData}
|
||||
onSaveSystemPrompt={onSaveSystemPrompt}
|
||||
isSaveSystemPromptError={isSaveSystemPromptError}
|
||||
isAnyPromptSaving={isAnyPromptSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showDefaultPromptPreview && (
|
||||
<TextPreviewDialog
|
||||
content={defaultSystemPrompt}
|
||||
fileName="Default System Prompt"
|
||||
onClose={() => setShowDefaultPromptPreview(false)}
|
||||
/>
|
||||
<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}
|
||||
isWorkspaceTTLLoadError={isWorkspaceTTLLoadError}
|
||||
onSaveWorkspaceTTL={onSaveWorkspaceTTL}
|
||||
isSavingWorkspaceTTL={isSavingWorkspaceTTL}
|
||||
isSaveWorkspaceTTLError={isSaveWorkspaceTTLError}
|
||||
/>
|
||||
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<RetentionPeriodSettings
|
||||
retentionDaysData={retentionDaysData}
|
||||
isRetentionDaysLoading={isRetentionDaysLoading}
|
||||
isRetentionDaysLoadError={isRetentionDaysLoadError}
|
||||
onSaveRetentionDays={onSaveRetentionDays}
|
||||
isSavingRetentionDays={isSavingRetentionDays}
|
||||
isSaveRetentionDaysError={isSaveRetentionDaysError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
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 { cn } from "#/utils/cn";
|
||||
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface PersonalInstructionsSettingsProps {
|
||||
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
|
||||
onSaveUserPrompt: (
|
||||
req: TypesGen.UserChatCustomPrompt,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingUserPrompt: boolean;
|
||||
isSaveUserPromptError: boolean;
|
||||
isAnyPromptSaving: boolean;
|
||||
}
|
||||
|
||||
export const PersonalInstructionsSettings: FC<
|
||||
PersonalInstructionsSettingsProps
|
||||
> = ({
|
||||
userPromptData,
|
||||
onSaveUserPrompt,
|
||||
isSaveUserPromptError,
|
||||
isAnyPromptSaving,
|
||||
}) => {
|
||||
const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false);
|
||||
|
||||
const form = useFormik({
|
||||
initialValues: {
|
||||
custom_prompt: userPromptData?.custom_prompt ?? "",
|
||||
},
|
||||
enableReinitialize: true,
|
||||
onSubmit: (values, helpers) => {
|
||||
onSaveUserPrompt(
|
||||
{ custom_prompt: values.custom_prompt },
|
||||
{ onSuccess: () => helpers.resetForm() },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const userInvisibleCharCount = countInvisibleCharacters(
|
||||
form.values.custom_prompt,
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="space-y-2" onSubmit={form.handleSubmit}>
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Personal Instructions
|
||||
</h3>
|
||||
<p className="!mt-0.5 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",
|
||||
isUserPromptOverflowing && "overflow-y-auto [scrollbar-width:thin]",
|
||||
)}
|
||||
name="custom_prompt"
|
||||
placeholder="Additional behavior, style, and tone preferences"
|
||||
value={form.values.custom_prompt}
|
||||
onChange={form.handleChange}
|
||||
onHeightChange={(height) => setIsUserPromptOverflowing(height >= 240)}
|
||||
disabled={isAnyPromptSaving}
|
||||
minRows={1}
|
||||
/>
|
||||
{userInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
This text contains {userInvisibleCharCount} invisible Unicode{" "}
|
||||
{userInvisibleCharCount !== 1 ? "characters" : "character"} that
|
||||
could hide content. These will be stripped on save.
|
||||
</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>
|
||||
{isSaveUserPromptError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save personal instructions.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import * as Yup from "yup";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { AdminBadge } from "./AdminBadge";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface RetentionPeriodSettingsProps {
|
||||
retentionDaysData: TypesGen.ChatRetentionDaysResponse | undefined;
|
||||
isRetentionDaysLoading: boolean;
|
||||
isRetentionDaysLoadError: boolean;
|
||||
onSaveRetentionDays: (
|
||||
req: TypesGen.UpdateChatRetentionDaysRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingRetentionDays: boolean;
|
||||
isSaveRetentionDaysError: boolean;
|
||||
}
|
||||
|
||||
// Keep in sync with retentionDaysMaximum in coderd/exp_chats.go.
|
||||
const validationSchema = Yup.object({
|
||||
retention_days: Yup.number()
|
||||
.integer("Retention days must be a whole number.")
|
||||
.min(1, "Retention period must be at least 1 day.")
|
||||
.max(3650, "Must not exceed 3650 days (~10 years).")
|
||||
.required("Retention days is required."),
|
||||
});
|
||||
|
||||
export const RetentionPeriodSettings: FC<RetentionPeriodSettingsProps> = ({
|
||||
retentionDaysData,
|
||||
isRetentionDaysLoading,
|
||||
isRetentionDaysLoadError,
|
||||
onSaveRetentionDays,
|
||||
isSavingRetentionDays,
|
||||
isSaveRetentionDaysError,
|
||||
}) => {
|
||||
const [retentionToggled, setRetentionToggled] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const serverRetentionDays = retentionDaysData?.retention_days ?? 30;
|
||||
const isRetentionEnabled = retentionToggled ?? serverRetentionDays > 0;
|
||||
|
||||
const form = useFormik({
|
||||
initialValues: { retention_days: serverRetentionDays },
|
||||
enableReinitialize: true,
|
||||
validationSchema,
|
||||
onSubmit: (values, helpers) => {
|
||||
onSaveRetentionDays(
|
||||
{ retention_days: values.retention_days },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRetentionToggled(null);
|
||||
helpers.resetForm();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const resetRetentionState = () => {
|
||||
setRetentionToggled(null);
|
||||
form.resetForm();
|
||||
};
|
||||
|
||||
const handleToggleRetention = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const days = serverRetentionDays > 0 ? serverRetentionDays : 30;
|
||||
setRetentionToggled(true);
|
||||
void form.setFieldValue("retention_days", days);
|
||||
onSaveRetentionDays(
|
||||
{ retention_days: days },
|
||||
{
|
||||
onSuccess: resetRetentionState,
|
||||
onError: resetRetentionState,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setRetentionToggled(false);
|
||||
void form.setFieldValue("retention_days", 0);
|
||||
onSaveRetentionDays(
|
||||
{ retention_days: 0 },
|
||||
{
|
||||
onSuccess: resetRetentionState,
|
||||
onError: resetRetentionState,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
<Switch
|
||||
checked={isRetentionEnabled}
|
||||
onCheckedChange={handleToggleRetention}
|
||||
aria-label="Enable conversation retention"
|
||||
disabled={isSavingRetentionDays || isRetentionDaysLoading}
|
||||
/>
|
||||
</div>
|
||||
{isRetentionEnabled && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
name="retention_days"
|
||||
min={1}
|
||||
max={3650}
|
||||
step={1}
|
||||
aria-label="Conversation retention period in days"
|
||||
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"
|
||||
/>
|
||||
{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 ||
|
||||
!!form.errors.retention_days
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isSaveRetentionDaysError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save retention setting.
|
||||
</p>
|
||||
)}
|
||||
{isRetentionDaysLoadError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load retention setting.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
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 { Switch } from "#/components/Switch/Switch";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
|
||||
import { AdminBadge } from "./AdminBadge";
|
||||
import { TextPreviewDialog } from "./TextPreviewDialog";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface SystemInstructionsSettingsProps {
|
||||
systemPromptData: TypesGen.ChatSystemPromptResponse | undefined;
|
||||
onSaveSystemPrompt: (
|
||||
req: TypesGen.UpdateChatSystemPromptRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSaveSystemPromptError: boolean;
|
||||
isAnyPromptSaving: boolean;
|
||||
}
|
||||
|
||||
export const SystemInstructionsSettings: FC<
|
||||
SystemInstructionsSettingsProps
|
||||
> = ({
|
||||
systemPromptData,
|
||||
onSaveSystemPrompt,
|
||||
isSaveSystemPromptError,
|
||||
isAnyPromptSaving,
|
||||
}) => {
|
||||
const [showDefaultPromptPreview, setShowDefaultPromptPreview] =
|
||||
useState(false);
|
||||
const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] =
|
||||
useState(false);
|
||||
|
||||
const hasLoadedSystemPrompt = systemPromptData !== undefined;
|
||||
const defaultSystemPrompt = systemPromptData?.default_system_prompt ?? "";
|
||||
|
||||
const form = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
system_prompt: systemPromptData?.system_prompt ?? "",
|
||||
include_default_system_prompt:
|
||||
systemPromptData?.include_default_system_prompt ?? false,
|
||||
},
|
||||
onSubmit: (values, { resetForm }) => {
|
||||
onSaveSystemPrompt(values, {
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const systemInvisibleCharCount = countInvisibleCharacters(
|
||||
form.values.system_prompt,
|
||||
);
|
||||
const isSystemPromptDisabled = isAnyPromptSaving || !hasLoadedSystemPrompt;
|
||||
|
||||
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">
|
||||
System Instructions
|
||||
</h3>
|
||||
<AdminBadge />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-content-primary">
|
||||
<span>Include Coder Agents default system prompt</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() => setShowDefaultPromptPreview(true)}
|
||||
disabled={!hasLoadedSystemPrompt}
|
||||
className="min-w-0 px-0 text-content-link hover:text-content-link"
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.values.include_default_system_prompt}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setFieldValue("include_default_system_prompt", checked)
|
||||
}
|
||||
aria-label="Include Coder Agents default system prompt"
|
||||
disabled={isSystemPromptDisabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
{form.values.include_default_system_prompt
|
||||
? "The built-in Coder Agents prompt is prepended. Additional instructions below are appended."
|
||||
: "Only the additional instructions below are used. When empty, no deployment-wide system prompt is sent."}
|
||||
</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",
|
||||
isSystemPromptOverflowing &&
|
||||
"overflow-y-auto [scrollbar-width:thin]",
|
||||
)}
|
||||
placeholder="Additional instructions for all users"
|
||||
name="system_prompt"
|
||||
value={form.values.system_prompt}
|
||||
onChange={form.handleChange}
|
||||
onHeightChange={(height) =>
|
||||
setIsSystemPromptOverflowing(height >= 240)
|
||||
}
|
||||
disabled={isSystemPromptDisabled}
|
||||
minRows={1}
|
||||
/>
|
||||
{systemInvisibleCharCount > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
This text contains {systemInvisibleCharCount} invisible Unicode{" "}
|
||||
{systemInvisibleCharCount !== 1 ? "characters" : "character"} that
|
||||
could hide content. These will be stripped on save.
|
||||
</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>
|
||||
{isSaveSystemPromptError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save system prompt.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{showDefaultPromptPreview && (
|
||||
<TextPreviewDialog
|
||||
content={defaultSystemPrompt}
|
||||
fileName="Default System Prompt"
|
||||
onClose={() => setShowDefaultPromptPreview(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { AdminBadge } from "./AdminBadge";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface VirtualDesktopSettingsProps {
|
||||
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
|
||||
onSaveDesktopEnabled: (
|
||||
req: TypesGen.UpdateChatDesktopEnabledRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingDesktopEnabled: boolean;
|
||||
isSaveDesktopEnabledError: boolean;
|
||||
}
|
||||
|
||||
export const VirtualDesktopSettings: FC<VirtualDesktopSettingsProps> = ({
|
||||
desktopEnabledData,
|
||||
onSaveDesktopEnabled,
|
||||
isSavingDesktopEnabled,
|
||||
isSaveDesktopEnabledError,
|
||||
}) => {
|
||||
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 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>
|
||||
<Switch
|
||||
checked={desktopEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveDesktopEnabled({ enable_desktop: checked })
|
||||
}
|
||||
aria-label="Enable"
|
||||
disabled={isSavingDesktopEnabled}
|
||||
/>
|
||||
</div>
|
||||
{isSaveDesktopEnabledError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save desktop setting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import * as Yup from "yup";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { AdminBadge } from "./AdminBadge";
|
||||
import { DurationField } from "./DurationField/DurationField";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface WorkspaceAutostopSettingsProps {
|
||||
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
|
||||
isWorkspaceTTLLoading: boolean;
|
||||
isWorkspaceTTLLoadError: boolean;
|
||||
onSaveWorkspaceTTL: (
|
||||
req: TypesGen.UpdateChatWorkspaceTTLRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingWorkspaceTTL: boolean;
|
||||
isSaveWorkspaceTTLError: boolean;
|
||||
}
|
||||
|
||||
const maxTTLMs = 30 * 24 * 60 * 60_000; // 30 days
|
||||
|
||||
export const WorkspaceAutostopSettings: FC<WorkspaceAutostopSettingsProps> = ({
|
||||
workspaceTTLData,
|
||||
isWorkspaceTTLLoading,
|
||||
isWorkspaceTTLLoadError,
|
||||
onSaveWorkspaceTTL,
|
||||
isSavingWorkspaceTTL,
|
||||
isSaveWorkspaceTTLError,
|
||||
}) => {
|
||||
// ── Toggle state (fires immediate mutations, not a form submit) ──
|
||||
const [autostopToggled, setAutostopToggled] = useState<boolean | null>(null);
|
||||
|
||||
// ── Derived state ──
|
||||
const serverTTLMs = workspaceTTLData?.workspace_ttl_ms ?? 0;
|
||||
const isAutostopEnabled = autostopToggled ?? serverTTLMs > 0;
|
||||
|
||||
// ── Form (for editing the TTL value) ──
|
||||
const validationSchema = Yup.object({
|
||||
workspace_ttl_ms: Yup.number()
|
||||
.required()
|
||||
.when([], {
|
||||
is: () => isAutostopEnabled,
|
||||
then: (schema) =>
|
||||
schema.moreThan(0, "Duration must be greater than zero."),
|
||||
})
|
||||
.max(maxTTLMs, "Must not exceed 30 days (720 hours)."),
|
||||
});
|
||||
|
||||
const form = useFormik({
|
||||
initialValues: { workspace_ttl_ms: serverTTLMs },
|
||||
enableReinitialize: true,
|
||||
validationSchema,
|
||||
onSubmit: (values, helpers) => {
|
||||
onSaveWorkspaceTTL(
|
||||
{ workspace_ttl_ms: values.workspace_ttl_ms },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAutostopToggled(null);
|
||||
helpers.resetForm();
|
||||
},
|
||||
onError: () => setAutostopToggled(null),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Handlers ──
|
||||
const resetAutostopState = () => {
|
||||
setAutostopToggled(null);
|
||||
form.resetForm();
|
||||
};
|
||||
|
||||
const handleToggleAutostop = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// Defensive: restore server value if query cache is
|
||||
// stale; otherwise default to 1 hour.
|
||||
const defaultTTL = serverTTLMs > 0 ? serverTTLMs : 3_600_000;
|
||||
setAutostopToggled(true);
|
||||
void form.setFieldValue("workspace_ttl_ms", defaultTTL);
|
||||
onSaveWorkspaceTTL(
|
||||
{ workspace_ttl_ms: defaultTTL },
|
||||
{ onSuccess: resetAutostopState, onError: resetAutostopState },
|
||||
);
|
||||
} else {
|
||||
setAutostopToggled(false);
|
||||
void form.setFieldValue("workspace_ttl_ms", 0);
|
||||
onSaveWorkspaceTTL(
|
||||
{ workspace_ttl_ms: 0 },
|
||||
{ onSuccess: resetAutostopState, onError: resetAutostopState },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTLChange = (value: number) => {
|
||||
void form.setFieldValue("workspace_ttl_ms", value);
|
||||
// Latch the toggle open while the user is editing
|
||||
// so a background refetch cannot unmount the field.
|
||||
if (autostopToggled === null) {
|
||||
setAutostopToggled(true);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
<Switch
|
||||
checked={isAutostopEnabled}
|
||||
onCheckedChange={handleToggleAutostop}
|
||||
aria-label="Enable default autostop"
|
||||
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
|
||||
/>
|
||||
</div>
|
||||
{isAutostopEnabled && (
|
||||
<DurationField
|
||||
valueMs={form.values.workspace_ttl_ms}
|
||||
onChange={handleTTLChange}
|
||||
label="Autostop Fallback"
|
||||
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
|
||||
error={!!fieldError}
|
||||
helperText={fieldError}
|
||||
/>
|
||||
)}
|
||||
{isAutostopEnabled && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isSavingWorkspaceTTL || !form.dirty || !!fieldError}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isSaveWorkspaceTTLError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save autostop setting.
|
||||
</p>
|
||||
)}
|
||||
{isWorkspaceTTLLoadError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load autostop setting.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -2,13 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_resetForTesting,
|
||||
getChimeEnabled,
|
||||
getKylesophyEnabled,
|
||||
isKylesophyForced,
|
||||
KYLEOSOPHY_SOUNDS,
|
||||
LOCK_HOLD_MS,
|
||||
maybePlayChime,
|
||||
setChimeEnabled,
|
||||
setKylesophyEnabled,
|
||||
} from "./chime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,40 +70,6 @@ describe("getChimeEnabled / setChimeEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kyleosophy preference helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getKylesophyEnabled / setKylesophyEnabled", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("defaults to false when nothing is stored", () => {
|
||||
expect(getKylesophyEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when stored as 'true'", () => {
|
||||
localStorage.setItem("agents.kyleosophy", "true");
|
||||
expect(getKylesophyEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when stored as 'false'", () => {
|
||||
localStorage.setItem("agents.kyleosophy", "false");
|
||||
expect(getKylesophyEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("setKylesophyEnabled persists the value", () => {
|
||||
setKylesophyEnabled(false);
|
||||
expect(localStorage.getItem("agents.kyleosophy")).toBe("false");
|
||||
expect(getKylesophyEnabled()).toBe(false);
|
||||
|
||||
setKylesophyEnabled(true);
|
||||
expect(localStorage.getItem("agents.kyleosophy")).toBe("true");
|
||||
expect(getKylesophyEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// maybePlayChime
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -275,79 +237,4 @@ describe("maybePlayChime", () => {
|
||||
// Should play immediately without needing to advance timers.
|
||||
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// -- Kyleosophy sound selection --
|
||||
|
||||
it("uses a kyleosophy sound when kyleosophy is enabled", async () => {
|
||||
setKylesophyEnabled(true);
|
||||
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
// Pin the random selection so the test is deterministic.
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||
|
||||
const audioSpy = vi.spyOn(globalThis, "Audio" as never);
|
||||
|
||||
await triggerAndSettle("running", "waiting", "chat-1", "chat-2");
|
||||
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||
expect(audioSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const url = (audioSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as string;
|
||||
// Math.floor(0.5 * 8) = 4 → "/chime_5.mp3"
|
||||
expect(url).toBe("/chime_5.mp3");
|
||||
expect(KYLEOSOPHY_SOUNDS).toContain(url);
|
||||
});
|
||||
|
||||
it("uses default chime.mp3 when kyleosophy is disabled", async () => {
|
||||
setKylesophyEnabled(false);
|
||||
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
|
||||
const audioSpy = vi.spyOn(globalThis, "Audio" as never);
|
||||
|
||||
await triggerAndSettle("running", "waiting", "chat-1", "chat-2");
|
||||
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||
expect(audioSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const url = (audioSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as string;
|
||||
expect(url).toBe("/chime.mp3");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isKylesophyForced
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isKylesophyForced", () => {
|
||||
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"location",
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLocationDescriptor) {
|
||||
Object.defineProperty(globalThis, "location", originalLocationDescriptor);
|
||||
} else {
|
||||
// If location did not originally exist, remove the stub.
|
||||
delete (globalThis as Record<string, unknown>).location;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true on dev.coder.com", () => {
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
value: { hostname: "dev.coder.com" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(isKylesophyForced()).toBe(true);
|
||||
expect(getKylesophyEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on other hosts", () => {
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
value: { hostname: "coder.example.com" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(isKylesophyForced()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const CHIME_PREFERENCE_KEY = "agents.chime-on-completion";
|
||||
const KYLEOSOPHY_PREFERENCE_KEY = "agents.kyleosophy";
|
||||
|
||||
export function getChimeEnabled(): boolean {
|
||||
try {
|
||||
@@ -21,79 +20,24 @@ export function setChimeEnabled(enabled: boolean): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Kyleosophy mode is active. Force-enabled on
|
||||
* dev.coder.com because the people deserve Kyle.
|
||||
*/
|
||||
export function getKylesophyEnabled(): boolean {
|
||||
if (isKylesophyForced()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(KYLEOSOPHY_PREFERENCE_KEY);
|
||||
return stored === null ? false : stored === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current deployment force-enables Kyleosophy,
|
||||
* bypassing the user preference.
|
||||
*/
|
||||
export function isKylesophyForced(): boolean {
|
||||
try {
|
||||
return globalThis.location?.hostname === "dev.coder.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setKylesophyEnabled(enabled: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(KYLEOSOPHY_PREFERENCE_KEY, String(enabled));
|
||||
} catch {
|
||||
// Silently ignore storage errors (e.g. private browsing
|
||||
// quota exceeded).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative completion sounds for Kyleosophy mode. All are
|
||||
* shipped as static assets alongside chime.mp3.
|
||||
*/
|
||||
export const KYLEOSOPHY_SOUNDS: readonly string[] = [
|
||||
"/chime_1.mp3", // absolutely massive
|
||||
"/chime_2.mp3", // dope
|
||||
"/chime_3.mp3", // great
|
||||
"/chime_4.mp3", // oh god
|
||||
"/chime_5.mp3", // okay
|
||||
"/chime_6.mp3", // open up a pr
|
||||
"/chime_7.mp3", // sweet
|
||||
"/chime_8.mp3", // yep
|
||||
];
|
||||
|
||||
/**
|
||||
* Play a completion sound. When Kyleosophy is enabled a random
|
||||
* voice clip is selected; otherwise the default bell chime is
|
||||
* used. The Audio element is cached and reused when the sound
|
||||
* URL hasn't changed between calls.
|
||||
* Play the completion chime audio file. The file is a short,
|
||||
* warm two-tone bell sound shipped as a static asset.
|
||||
*
|
||||
* A single Audio element is reused across calls so the browser
|
||||
* only fetches the file once.
|
||||
*/
|
||||
let chimeAudio: HTMLAudioElement | null = null;
|
||||
let lastSoundUrl: string | null = null;
|
||||
|
||||
/** @internal Reset cached Audio state between tests. */
|
||||
export function _resetForTesting(): void {
|
||||
chimeAudio = null;
|
||||
lastSoundUrl = null;
|
||||
}
|
||||
|
||||
function playChimeAudio(soundUrl = "/chime.mp3"): void {
|
||||
function playChimeAudio(): void {
|
||||
try {
|
||||
if (!chimeAudio || soundUrl !== lastSoundUrl) {
|
||||
chimeAudio?.pause();
|
||||
chimeAudio = new Audio(soundUrl);
|
||||
if (!chimeAudio) {
|
||||
chimeAudio = new Audio("/chime.mp3");
|
||||
chimeAudio.volume = 0.5;
|
||||
lastSoundUrl = soundUrl;
|
||||
}
|
||||
// Reset to the start in case a previous play hasn't
|
||||
// finished yet.
|
||||
@@ -136,9 +80,9 @@ export const LOCK_HOLD_MS = 2000;
|
||||
* Falls back to playing immediately when the Web Locks API is
|
||||
* not available (preserving the original single-tab behavior).
|
||||
*/
|
||||
function playChime(chatID: string, soundUrl?: string): void {
|
||||
function playChime(chatID: string): void {
|
||||
if (typeof navigator === "undefined" || !navigator.locks) {
|
||||
playChimeAudio(soundUrl);
|
||||
playChimeAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,7 +98,7 @@ function playChime(chatID: string, soundUrl?: string): void {
|
||||
return;
|
||||
}
|
||||
|
||||
playChimeAudio(soundUrl);
|
||||
playChimeAudio();
|
||||
|
||||
// Hold the lock briefly so that tabs receiving the
|
||||
// WebSocket event a bit later will see the lock as
|
||||
@@ -212,9 +156,5 @@ export function maybePlayChime(
|
||||
return;
|
||||
}
|
||||
|
||||
const soundUrl = getKylesophyEnabled()
|
||||
? KYLEOSOPHY_SOUNDS[Math.floor(Math.random() * KYLEOSOPHY_SOUNDS.length)]
|
||||
: undefined;
|
||||
|
||||
playChime(chatID, soundUrl);
|
||||
playChime(chatID);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user