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:
Cian Johnston
2026-04-08 14:01:38 +01:00
committed by GitHub
parent da5395a8ae
commit f820945d9f
18 changed files with 743 additions and 794 deletions
+3
View File
@@ -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);
});
});
+12 -72
View File
@@ -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.