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

234 lines
8.4 KiB
TypeScript

"use client";
import { SettingsInfo } from "./settings-info";
import { useEffect, useRef, useState } from "react";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api";
import { SettingsCard } from "./settings-card";
import { SettingsRow, type SettingsPresentation } from "./settings-group";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { useTranslation } from "react-i18next";
type TodoListPanelDraft = {
show: boolean;
onlyWhenNotEmpty: boolean;
};
function TodoListPanelRows({
draft,
saved,
onDraftChange,
}: {
draft: TodoListPanelDraft;
saved: TodoListPanelDraft;
onDraftChange: (update: Partial<TodoListPanelDraft>) => void;
}) {
const { t } = useTranslation();
return (
<div
data-testid="todo-list-panel-settings-card"
data-settings-dirty={
draft.show !== saved.show || draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty
}
>
<SettingsRow
label={t("settings:showAgentTodoListPanel")}
description={t("settings:todoShort")}
info={
<SettingsInfo label={t("settings:showAgentTodoListPanel")}>
{t("settings:pinTheAgentsLiveTodoChecklistAs")}
</SettingsInfo>
}
controlId="show-todo-list-panel"
touchTarget="switch"
isDirty={draft.show !== saved.show}
control={
<Switch
id="show-todo-list-panel"
checked={draft.show}
data-settings-dirty={draft.show !== saved.show}
onCheckedChange={(show) => onDraftChange({ show })}
className="shrink-0 cursor-pointer"
/>
}
/>
{draft.show && (
<SettingsRow
label={t("settings:onlyPinWhenTodoListIsNotEmpty")}
description={t("settings:todoNonemptyShort")}
info={
<SettingsInfo label={t("settings:onlyPinWhenTodoListIsNotEmpty")}>
{t("settings:onlyPinWhenTodoListIsNotEmptyDescription")}
</SettingsInfo>
}
controlId="todo-list-panel-only-when-not-empty"
touchTarget="switch"
isDirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
control={
<Switch
id="todo-list-panel-only-when-not-empty"
checked={draft.onlyWhenNotEmpty}
data-settings-dirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
onCheckedChange={(onlyWhenNotEmpty) => onDraftChange({ onlyWhenNotEmpty })}
className="shrink-0 cursor-pointer"
/>
}
/>
)}
</div>
);
}
function TodoListPanelCard({
draft,
saved,
onDraftChange,
}: {
draft: TodoListPanelDraft;
saved: TodoListPanelDraft;
onDraftChange: (update: Partial<TodoListPanelDraft>) => void;
}) {
const { t } = useTranslation();
const isDirty = draft.show !== saved.show || draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty;
return (
<SettingsCard isDirty={isDirty} data-testid="todo-list-panel-settings-card">
<CardHeader>
<CardTitle className="text-base">{t("settings:todoListPanel")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="show-todo-list-panel">{t("settings:showAgentTodoListPanel")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings:pinTheAgentsLiveTodoChecklistAs")}
</p>
</div>
<Switch
id="show-todo-list-panel"
checked={draft.show}
data-settings-dirty={draft.show !== saved.show}
onCheckedChange={(show) => onDraftChange({ show })}
className="shrink-0 cursor-pointer"
/>
</div>
{draft.show && (
<div className="flex min-h-11 items-center justify-between gap-4 border-t pt-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="todo-list-panel-only-when-not-empty">
{t("settings:onlyPinWhenTodoListIsNotEmpty")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:onlyPinWhenTodoListIsNotEmptyDescription")}
</p>
</div>
<Switch
id="todo-list-panel-only-when-not-empty"
checked={draft.onlyWhenNotEmpty}
data-settings-dirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
onCheckedChange={(onlyWhenNotEmpty) => onDraftChange({ onlyWhenNotEmpty })}
className="shrink-0 cursor-pointer"
/>
</div>
)}
</div>
</CardContent>
</SettingsCard>
);
}
/**
* Edits the per-user preferences that control whether the agent's live todo
* checklist is auto-pinned as a Todos tab in the desktop task workbench's
* right panel. The main preference is the master gate; the "only when not
* empty" sub-option is inhibited (hidden, never disabled) while the master
* gate is off, so its saved value survives and reappears once the master gate
* is re-enabled. Manual adds from the workbench's "+" menu are never gated by
* either preference.
*/
export function TodoListPanelSettings({
presentation = "card",
}: {
presentation?: SettingsPresentation;
}) {
const showTodoListPanel = useAppStore((state) => state.userSettings.showTodoListPanel);
const onlyPinWhenNotEmpty = useAppStore(
(state) => state.userSettings.showTodoListPanelOnlyWhenNotEmpty,
);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const storeApi = useAppStoreApi();
const [saved, setSaved] = useState<TodoListPanelDraft>({
show: showTodoListPanel,
onlyWhenNotEmpty: onlyPinWhenNotEmpty,
});
const [draft, setDraft] = useState<TodoListPanelDraft>(saved);
const draftRef = useRef(draft);
draftRef.current = draft;
const isDirty = draft.show !== saved.show || draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty;
useEffect(() => {
setSaved((previous) => {
if (
draftRef.current.show === previous.show &&
draftRef.current.onlyWhenNotEmpty === previous.onlyWhenNotEmpty
) {
setDraft({ show: showTodoListPanel, onlyWhenNotEmpty: onlyPinWhenNotEmpty });
}
return { show: showTodoListPanel, onlyWhenNotEmpty: onlyPinWhenNotEmpty };
});
}, [showTodoListPanel, onlyPinWhenNotEmpty]);
useSettingsSaveContributor({
id: "general-todo-list-panel",
revision: JSON.stringify(draft),
isDirty,
save: async (revision) => {
const submitted = JSON.parse(String(revision)) as TodoListPanelDraft;
const before = storeApi.getState().userSettings;
await updateUserSettings({
show_todo_list_panel: submitted.show,
show_todo_list_panel_only_when_not_empty: submitted.onlyWhenNotEmpty,
});
// Only adopt our submission as the saved baseline when nothing newer
// landed mid-flight (e.g. a WS settings push from another tab): the
// hydration effect has already moved `saved` to the newer values, so
// overwriting them here would leave the UI falsely clean against a
// stale submission. On drift, keep the newer baseline so the draft
// stays dirty and the user can reconcile.
const current = storeApi.getState().userSettings;
const drifted =
current.showTodoListPanel !== before.showTodoListPanel ||
current.showTodoListPanelOnlyWhenNotEmpty !== before.showTodoListPanelOnlyWhenNotEmpty;
if (drifted) return;
setSaved(submitted);
setUserSettings({
...current,
showTodoListPanel: submitted.show,
showTodoListPanelOnlyWhenNotEmpty: submitted.onlyWhenNotEmpty,
});
},
discard: () => setDraft(saved),
});
if (presentation === "row") {
return (
<TodoListPanelRows
draft={draft}
saved={saved}
onDraftChange={(update) => setDraft((current) => ({ ...current, ...update }))}
/>
);
}
return (
<TodoListPanelCard
draft={draft}
saved={saved}
onDraftChange={(update) => setDraft((current) => ({ ...current, ...update }))}
/>
);
}