Files
kandev/apps/web/components/settings/general-settings.tsx
T
2026-09-22 21:24:43 +01:00

612 lines
21 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@/components/theme/app-theme";
import {
IconBinaryTree,
IconCommand,
IconPalette,
IconKeyboard,
IconArchive,
IconArrowBackUp,
IconListCheck,
IconHome,
} from "@tabler/icons-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Separator } from "@kandev/ui/separator";
import { SettingsSection } from "@/components/settings/settings-section";
import { settingsControlClassName } from "@/components/settings/settings-control";
import { SettingsRow } from "@/components/settings/settings-group";
import { SettingsPageHeader } from "@/components/settings/settings-typography";
import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-card";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api";
import type { Theme } from "@/lib/settings/types";
import type { UserSettingsState } from "@/lib/state/slices/settings/types";
import { ArchiveConfirmationSettings } from "@/components/settings/archive-confirmation-settings";
import { CreationAutoFocusSettings } from "@/components/settings/creation-auto-focus-settings";
import { PreventAutoStartAgentSettings } from "@/components/settings/prevent-auto-start-agent-settings";
import { LanguageSettings } from "@/components/settings/language-settings";
import { MCPTaskAgentProfileDefaultSettings } from "@/components/settings/mcp-task-agent-profile-default-settings";
import { UnreadDividerSettings } from "@/components/settings/unread-divider-settings";
import { AgentGeneratedTaskTitleSettings } from "@/components/settings/agent-generated-task-title-settings";
import { AnchoredPromptBarSettings } from "@/components/settings/anchored-prompt-bar-settings";
import { TodoListPanelSettings } from "@/components/settings/todo-list-panel-settings";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import { buildPluginShortcutEntries } from "@/lib/keyboard/plugin-shortcuts";
import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
import { StartupPageSettingsCard } from "@/components/settings/startup-page-settings-card";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { SleepInhibitionSettings } from "@/components/settings/sleep-inhibition-settings";
import { SettingsMenuModeCard } from "@/components/settings/settings-menu-mode-card";
import { ChatMotionSettingsCard } from "./chat-motion-settings-card";
import { RichOutputMotionSettingsCard } from "@/components/settings/rich-output-motion-settings-card";
import { AppearanceAccountSections } from "@/components/settings/appearance-account-sections";
import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision";
import {
appearanceRevision,
parseSidebarHoverDelay,
buildAppearanceUserSettingsPatch,
createAppearanceSavedState,
rebaseAppearanceDraft,
type AppearanceState,
} from "@/components/settings/appearance-settings-state";
function ThemeSettingsCard({
theme,
isDirty,
onChange,
}: {
theme: Theme;
isDirty: boolean;
onChange: (theme: Theme) => void;
}) {
const { t } = useTranslation();
return (
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.colorTheme}
data-testid="theme-settings-card"
label={t("settings:colorTheme")}
controlId="theme"
control={
<Select value={theme} onValueChange={(value) => onChange(value as Theme)}>
<SelectTrigger
id="theme"
className={settingsControlClassName()}
data-settings-dirty={isDirty}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t("common:system")}</SelectItem>
<SelectItem value="light">{t("settings:light")}</SelectItem>
<SelectItem value="dark">{t("settings:dark")}</SelectItem>
</SelectContent>
</Select>
}
/>
);
}
function ChatSubmitKeyCard({
value,
isDirty,
onChange,
}: {
value: "enter" | "cmd_enter";
isDirty: boolean;
onChange: (value: "enter" | "cmd_enter") => void;
}) {
const { t } = useTranslation();
return (
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.submitShortcut}
data-testid="chat-submit-key-card"
label={t("settings:messageSubmitKey")}
description={
value === "cmd_enter"
? t("settings:pressCmdCtrlEnterToSend")
: t("settings:pressEnterToSendMessagesPress")
}
controlId="chat-submit-key"
descriptionId="chat-submit-key-description"
control={
<Select value={value} onValueChange={(next) => onChange(next as "enter" | "cmd_enter")}>
<SelectTrigger
id="chat-submit-key"
aria-describedby="chat-submit-key-description"
data-settings-dirty={isDirty}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cmd_enter">{t("settings:cmdCtrlEnterToSend")}</SelectItem>
<SelectItem value="enter">{t("settings:enterToSend")}</SelectItem>
</SelectContent>
</Select>
}
/>
);
}
function StartupPageSettingsSection({
value,
isDirty,
onChange,
}: {
value: UserSettingsState["startupPage"];
isDirty: boolean;
onChange: (startupPage: UserSettingsState["startupPage"]) => void;
}) {
const { t } = useTranslation();
return (
<>
<Separator />
<SettingsSection
icon={<IconHome className="h-5 w-5" />}
title={t("settings:startupPage")}
description={t("settings:chooseWhatOpensWhenKandevStarts")}
>
<StartupPageSettingsCard value={value} isDirty={isDirty} onChange={onChange} />
</SettingsSection>
<Separator />
</>
);
}
function SettingsMenuModeSection({
value,
isDirty,
onChange,
}: {
value: SettingsMenuMode;
isDirty: boolean;
onChange: (mode: SettingsMenuMode) => void;
}) {
const { t } = useTranslation();
return (
<>
<Separator />
<SettingsSection
icon={<IconBinaryTree className="h-5 w-5" />}
title={t("settings:settingsMenu")}
description={t("settings:chooseHowDeepTheSettingsMenuGoes")}
>
<SettingsMenuModeCard value={value} isDirty={isDirty} onChange={onChange} />
</SettingsSection>
</>
);
}
function AppearanceThemeSection({
theme,
isThemeDirty,
onThemeChange,
richOutputAnimationsEnabled,
isRichOutputMotionDirty,
onRichOutputMotionChange,
chatAnimationsEnabled,
isChatMotionDirty,
onChatMotionChange,
}: {
theme: Theme;
isThemeDirty: boolean;
onThemeChange: (theme: Theme) => void;
richOutputAnimationsEnabled: boolean;
isRichOutputMotionDirty: boolean;
onRichOutputMotionChange: (enabled: boolean) => void;
chatAnimationsEnabled: boolean;
isChatMotionDirty: boolean;
onChatMotionChange: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
return (
<SettingsSection
icon={<IconPalette className="h-5 w-5" />}
title={t("settings:appearance")}
description={t("settings:customizeHowTheApplicationLooks")}
>
<ThemeSettingsCard theme={theme} isDirty={isThemeDirty} onChange={onThemeChange} />
<RichOutputMotionSettingsCard
enabled={richOutputAnimationsEnabled}
isDirty={isRichOutputMotionDirty}
onChange={onRichOutputMotionChange}
/>
<ChatMotionSettingsCard
enabled={chatAnimationsEnabled}
isDirty={isChatMotionDirty}
onChange={onChatMotionChange}
/>
</SettingsSection>
);
}
export function TaskActionsSettings() {
const { t } = useTranslation();
return (
<div className="space-y-8">
<SettingsSection
icon={<IconArchive className="h-5 w-5" />}
title={t("settings:taskActions")}
description={t("settings:configureArchiveSafeguardsAndDefaultsFor")}
>
<div className="space-y-4">
<PreventAutoStartAgentSettings />
<CreationAutoFocusSettings />
<MCPTaskAgentProfileDefaultSettings />
<AgentGeneratedTaskTitleSettings />
<ArchiveConfirmationSettings />
<UnreadDividerSettings />
<SleepInhibitionSettings />
</div>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconArrowBackUp className="h-5 w-5" />}
title={t("settings:transcriptNavigation")}
description={t("settings:chooseWhichTranscriptNavigationControls")}
>
<AnchoredPromptBarSettings />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconListCheck className="h-5 w-5" />}
title={t("settings:todoListPanel")}
description={t("settings:pinTheAgentsLiveTodoChecklistAs")}
>
<TodoListPanelSettings />
</SettingsSection>
</div>
);
}
/**
* Register the Appearance page with the shared save coordinator.
*
* Split out of `AppearanceSettings` for length. It is also where the page's two
* kinds of setting meet: the account-level ones go out in one
* `updateUserSettings` call, while the theme and the settings menu shape are
* per-device and commit through their own preview/commit/restore pairs — one
* Save changes control over both.
*/
function useAppearanceSaveContributor({
draft,
draftRef,
editedDuringSaveRef,
saved,
setSaved,
setDraft,
}: {
draft: AppearanceState;
draftRef: { current: AppearanceState };
editedDuringSaveRef: { current: Set<keyof AppearanceState> | null };
saved: AppearanceState;
setSaved: (next: AppearanceState) => void;
setDraft: (next: AppearanceState) => void;
}) {
const setUserSettings = useAppStore((state) => state.setUserSettings);
const storeApi = useAppStoreApi();
const { previewTheme, commitTheme, restoreTheme } = useTheme();
const previewMenuMode = useAppStore((state) => state.previewSettingsMenuMode);
const commitMenuMode = useAppStore((state) => state.commitSettingsMenuMode);
const restoreMenuMode = useAppStore((state) => state.restoreSettingsMenuMode);
const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations);
const previewChatAnimations = useAppStore((state) => state.previewChatAnimations);
const commitRichOutputAnimations = useAppStore((state) => state.commitRichOutputAnimations);
const restoreRichOutputAnimations = useAppStore((state) => state.restoreRichOutputAnimations);
const commitChatAnimations = useAppStore((state) => state.commitChatAnimations);
const restoreChatAnimations = useAppStore((state) => state.restoreChatAnimations);
const { t } = useTranslation();
const delayValid = parseSidebarHoverDelay(draft.sidebarHoverDelayMs) !== null;
const revision = appearanceRevision(draft);
useSettingsSaveContributor({
id: "general-appearance",
canSave: delayValid,
invalidReason: delayValid ? undefined : t("settings:sidebarHoverDelayError"),
order: 10,
revision,
isDirty: revision !== appearanceRevision(saved),
save: async () => {
const submitted = draft;
const patch = buildAppearanceUserSettingsPatch(submitted, saved);
const settingsAtSubmit = storeApi.getState().userSettings;
const editedDuringSave = new Set<keyof AppearanceState>();
editedDuringSaveRef.current = editedDuringSave;
try {
const response = Object.keys(patch).length > 0 ? await updateUserSettings(patch) : null;
commitTheme(submitted.theme);
commitMenuMode(submitted.settingsMenuMode);
commitRichOutputAnimations(submitted.richOutputAnimationsEnabled);
commitChatAnimations(submitted.chatAnimationsEnabled);
// Keep edits made during the save visible over the submitted baseline.
if (draftRef.current.theme !== submitted.theme) {
previewTheme(draftRef.current.theme);
}
if (draftRef.current.settingsMenuMode !== submitted.settingsMenuMode) {
previewMenuMode(draftRef.current.settingsMenuMode);
}
if (
draftRef.current.richOutputAnimationsEnabled !== submitted.richOutputAnimationsEnabled
) {
previewRichOutputAnimations(draftRef.current.richOutputAnimationsEnabled);
}
if (draftRef.current.chatAnimationsEnabled !== submitted.chatAnimationsEnabled) {
previewChatAnimations(draftRef.current.chatAnimationsEnabled);
}
const latestUserSettings = storeApi.getState().userSettings;
const responseOrder = response
? compareUserSettingsRevisions(response.settings.revision, latestUserSettings.revision)
: null;
const responseIsCurrent =
responseOrder === null ? latestUserSettings === settingsAtSubmit : responseOrder >= 0;
const nextUserSettings =
response && responseIsCurrent
? mapUserSettingsResponse(response, latestUserSettings)
: latestUserSettings;
const confirmed = createAppearanceSavedState(
submitted.theme,
submitted.settingsMenuMode,
submitted.richOutputAnimationsEnabled,
nextUserSettings,
submitted.chatAnimationsEnabled,
);
setSaved(confirmed);
setDraft(rebaseAppearanceDraft(draftRef.current, submitted, confirmed, editedDuringSave));
if (response) setUserSettings(nextUserSettings);
} finally {
if (editedDuringSaveRef.current === editedDuringSave) {
editedDuringSaveRef.current = null;
}
}
},
discard: () => {
setDraft(saved);
restoreTheme();
restoreMenuMode();
restoreRichOutputAnimations();
restoreChatAnimations();
},
});
}
function useAppearanceDraftUpdater(
setDraft: (update: (current: AppearanceState) => AppearanceState) => void,
editedDuringSaveRef: { current: Set<keyof AppearanceState> | null },
) {
return useCallback(
(patch: Partial<AppearanceState>) =>
setDraft((current) => {
const next = { ...current, ...patch };
for (const field of Object.keys(patch) as Array<keyof AppearanceState>) {
if (current[field] !== next[field]) {
editedDuringSaveRef.current?.add(field);
}
}
return next;
}),
[editedDuringSaveRef, setDraft],
);
}
function AppearanceSettingsSections({
draft,
saved,
updateDraft,
previewTheme,
previewMenuMode,
previewRichOutputAnimations,
previewChatAnimations,
}: {
draft: AppearanceState;
saved: AppearanceState;
updateDraft: (patch: Partial<AppearanceState>) => void;
previewTheme: (theme: Theme) => void;
previewMenuMode: (mode: SettingsMenuMode) => void;
previewRichOutputAnimations: (enabled: boolean) => void;
previewChatAnimations: (enabled: boolean) => void;
}) {
return (
<>
<AppearanceThemeSection
theme={draft.theme}
isThemeDirty={draft.theme !== saved.theme}
onThemeChange={(theme) => {
updateDraft({ theme });
previewTheme(theme);
}}
chatAnimationsEnabled={draft.chatAnimationsEnabled}
isChatMotionDirty={draft.chatAnimationsEnabled !== saved.chatAnimationsEnabled}
onChatMotionChange={(chatAnimationsEnabled) => {
updateDraft({ chatAnimationsEnabled });
previewChatAnimations(chatAnimationsEnabled);
}}
richOutputAnimationsEnabled={draft.richOutputAnimationsEnabled}
isRichOutputMotionDirty={
draft.richOutputAnimationsEnabled !== saved.richOutputAnimationsEnabled
}
onRichOutputMotionChange={(richOutputAnimationsEnabled) => {
updateDraft({ richOutputAnimationsEnabled });
previewRichOutputAnimations(richOutputAnimationsEnabled);
}}
/>
<SettingsMenuModeSection
value={draft.settingsMenuMode}
isDirty={draft.settingsMenuMode !== saved.settingsMenuMode}
onChange={(settingsMenuMode) => {
updateDraft({ settingsMenuMode });
// The settings menu is on screen beside this page, so previewing is
// the whole point: you see the shape you are choosing.
previewMenuMode(settingsMenuMode);
}}
/>
<StartupPageSettingsSection
value={draft.startupPage}
isDirty={draft.startupPage !== saved.startupPage}
onChange={(startupPage) => updateDraft({ startupPage })}
/>
<LanguageSettings />
<AppearanceAccountSections draft={draft} saved={saved} updateDraft={updateDraft} />
</>
);
}
export function AppearanceSettings() {
const { t } = useTranslation();
const userSettings = useAppStore((state) => state.userSettings);
const { savedTheme, previewTheme } = useTheme();
const savedMenuMode = useAppStore((state) => state.settingsMenu.savedMode);
const savedRichOutputAnimations = useAppStore((state) => state.richOutputMotion.savedEnabled);
const savedChatAnimations = useAppStore((state) => state.chatMotion.savedEnabled);
const previewMenuMode = useAppStore((state) => state.previewSettingsMenuMode);
const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations);
const previewChatAnimations = useAppStore((state) => state.previewChatAnimations);
const [saved, setSaved] = useState(() =>
createAppearanceSavedState(
savedTheme,
savedMenuMode,
savedRichOutputAnimations,
userSettings,
savedChatAnimations,
),
);
const [draft, setDraft] = useState(saved);
const draftRef = useRef(draft);
const savedRef = useRef(saved);
const editedDuringSaveRef = useRef<Set<keyof AppearanceState> | null>(null);
draftRef.current = draft;
savedRef.current = saved;
useEffect(() => {
const previousSaved = savedRef.current;
const nextSaved = createAppearanceSavedState(
previousSaved.theme,
previousSaved.settingsMenuMode,
previousSaved.richOutputAnimationsEnabled,
userSettings,
previousSaved.chatAnimationsEnabled,
);
setDraft(
rebaseAppearanceDraft(
draftRef.current,
previousSaved,
nextSaved,
editedDuringSaveRef.current ?? undefined,
),
);
setSaved(nextSaved);
}, [userSettings]);
useAppearanceSaveContributor({
draft,
draftRef,
editedDuringSaveRef,
saved,
setSaved,
setDraft,
});
const updateDraft = useAppearanceDraftUpdater(setDraft, editedDuringSaveRef);
return (
<div className="space-y-8">
<SettingsPageHeader
title={t("settings:appearance")}
description={t("settings:customizeHowTheApplicationLooks")}
/>
<Separator />
<AppearanceSettingsSections
draft={draft}
saved={saved}
updateDraft={updateDraft}
previewTheme={previewTheme}
previewMenuMode={previewMenuMode}
previewRichOutputAnimations={previewRichOutputAnimations}
previewChatAnimations={previewChatAnimations}
/>
</div>
);
}
export function KeyboardShortcutsSettings() {
const { t } = useTranslation();
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const storeApi = useAppStoreApi();
const { items: pluginItems } = usePlugins();
const pluginShortcutEntries = buildPluginShortcutEntries(pluginItems);
const [saved, setSaved] = useState(() => ({
chatSubmitKey: userSettings.chatSubmitKey,
keyboardShortcuts: userSettings.keyboardShortcuts as StoredShortcutOverrides,
}));
const [draft, setDraft] = useState(saved);
const revision = JSON.stringify(draft);
useSettingsSaveContributor({
id: "general-keyboard-shortcuts",
revision,
isDirty: revision !== JSON.stringify(saved),
save: async () => {
const submitted = draft;
const current = storeApi.getState().userSettings;
await updateUserSettings({
workspace_id: current.workspaceId || "",
repository_ids: current.repositoryIds || [],
chat_submit_key: submitted.chatSubmitKey,
keyboard_shortcuts: submitted.keyboardShortcuts,
});
setSaved(submitted);
setUserSettings({ ...storeApi.getState().userSettings, ...submitted });
},
discard: () => setDraft(saved),
});
return (
<div className="space-y-8">
<SettingsSection
icon={<IconKeyboard className="h-5 w-5" />}
title={t("settings:chatInput")}
description={t("settings:configureChatInputBehavior")}
>
<ChatSubmitKeyCard
value={draft.chatSubmitKey}
isDirty={draft.chatSubmitKey !== saved.chatSubmitKey}
onChange={(chatSubmitKey) => setDraft((current) => ({ ...current, chatSubmitKey }))}
/>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconCommand className="h-5 w-5" />}
title={t("settings:keyboardShortcuts")}
description={t("settings:customizeKeyboardShortcutsForTheCommand")}
>
<KeyboardShortcutsCard
overrides={draft.keyboardShortcuts}
baselineOverrides={saved.keyboardShortcuts}
onChange={(keyboardShortcuts) =>
setDraft((current) => ({ ...current, keyboardShortcuts }))
}
pluginEntries={pluginShortcutEntries}
/>
</SettingsSection>
</div>
);
}