From 9523a1b70bb99fb1a446fba6b18aca6b004a6ac4 Mon Sep 17 00:00:00 2001 From: Ghvst Date: Tue, 24 Feb 2026 10:59:14 +0100 Subject: [PATCH 1/2] fix(ui): add undo toast and commit-only persistence for high-impact settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-impact settings (agent mode, budget ceiling, project schedule) previously applied instantly on click/drag with no way to revert. This is risky because these settings directly control agent behavior and token consumption. - Add reusable `undoToast()` in toast.tsx — shows a 5-second undo window (Gmail-style) for any setting change - SchedulingSection: agent mode buttons now show undo toast with previous value restoration - BudgetSection: slider uses local state during drag, only persists on mouse-up/touch-end via Radix `onValueCommit`, with undo toast - ProjectSection: schedule mode buttons and budget slider get the same undo toast and commit-only slider behavior SUSTN-Task: acb368a4-bf78-4c80-b300-b65594ddee3c --- .../settings/sections/BudgetSection.tsx | 35 +++++++++-- .../settings/sections/ProjectSection.tsx | 63 ++++++++++++++++--- .../settings/sections/SchedulingSection.tsx | 17 ++++- src/ui/lib/toast.tsx | 32 +++++++++- 4 files changed, 132 insertions(+), 15 deletions(-) diff --git a/src/ui/components/settings/sections/BudgetSection.tsx b/src/ui/components/settings/sections/BudgetSection.tsx index 7b329f1..f645942 100644 --- a/src/ui/components/settings/sections/BudgetSection.tsx +++ b/src/ui/components/settings/sections/BudgetSection.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from "react"; import { Switch } from "@ui/components/ui/switch"; import { Slider } from "@ui/components/ui/slider"; import { SettingsRow } from "../SettingsRow"; @@ -5,14 +6,25 @@ import { useGlobalSettings, useUpdateGlobalSetting, } from "@core/api/useSettings"; +import { undoToast } from "@ui/lib/toast"; export function BudgetSection() { const { data: settings } = useGlobalSettings(); const { mutate: updateSetting } = useUpdateGlobalSetting(); - if (!settings) return null; + // Local slider state — only persisted on mouse-up / touch-end + const [localCeiling, setLocalCeiling] = useState( + undefined, + ); + + // Sync from server when settings load or change externally + useEffect(() => { + if (settings) setLocalCeiling(settings.budgetCeilingPercent); + }, [settings]); + + if (!settings || localCeiling === undefined) return null; - const ceiling = settings.budgetCeilingPercent; + const ceiling = localCeiling; const reserved = 100 - ceiling; return ( @@ -44,11 +56,26 @@ export function BudgetSection() { + setLocalCeiling(value) + } + onValueCommit={([value]) => { + const prev = settings.budgetCeilingPercent; + if (prev === value) return; updateSetting({ key: "budgetCeilingPercent", value, - }) - } + }); + undoToast( + `Budget ceiling → ${value}%`, + () => { + setLocalCeiling(prev); + updateSetting({ + key: "budgetCeilingPercent", + value: prev, + }); + }, + ); + }} min={10} max={100} step={5} diff --git a/src/ui/components/settings/sections/ProjectSection.tsx b/src/ui/components/settings/sections/ProjectSection.tsx index a68b194..70bc7ab 100644 --- a/src/ui/components/settings/sections/ProjectSection.tsx +++ b/src/ui/components/settings/sections/ProjectSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useRepositories, useGitBranches } from "@core/api/useRepositories"; import { useGlobalSettings, @@ -19,6 +19,7 @@ import { Slider } from "@ui/components/ui/slider"; import type { BranchPrefixMode } from "@core/types/settings"; import type { ScheduleMode } from "@core/types/agent"; import { Trash2, Clock, Zap, Hand } from "lucide-react"; +import { undoToast } from "@ui/lib/toast"; interface ProjectSectionProps { repositoryId: string; @@ -53,6 +54,22 @@ export function ProjectSection({ const agentPrefsDirty = useRef(false); const scanPrefsDirty = useRef(false); + // Local slider state for project budget — only persisted on mouse-up + const [localBudget, setLocalBudget] = useState( + undefined, + ); + const serverBudget = useMemo( + () => + overrides && globalSettings + ? (overrides.overrideBudgetCeilingPercent ?? + globalSettings.budgetCeilingPercent) + : undefined, + [overrides, globalSettings], + ); + useEffect(() => { + if (serverBudget !== undefined) setLocalBudget(serverBudget); + }, [serverBudget]); + const repo = repositories?.find((r) => r.id === repositoryId); const { data: branches } = useGitBranches(repo?.path); @@ -124,6 +141,7 @@ export function ProjectSection({ const effectiveSchedule = agentConfig?.scheduleMode ?? (globalSettings.agentMode as ScheduleMode); const effectiveBudget = + localBudget ?? overrides.overrideBudgetCeilingPercent ?? globalSettings.budgetCeilingPercent; @@ -421,13 +439,23 @@ export function ProjectSection({ + + ), + { id, duration: 5000 }, + ); +} export function savedToast() { toast.custom( From 85e4421e00ada04ee86cd589670ef15acf7835a6 Mon Sep 17 00:00:00 2001 From: Ghvst Date: Tue, 24 Feb 2026 11:02:53 +0100 Subject: [PATCH 2/2] fix(ui): prevent saved toast from overlapping undo toast on high-impact settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The undo toast (shown for agent mode, budget ceiling, and project schedule/budget changes) was being immediately followed by a "Saved" toast from the mutation's onSuccess handler. Both toasts would appear simultaneously or the saved toast would replace the undo toast, creating a confusing UX and cutting the undo window short. - Add an `undoActive` flag in the toast module that suppresses `savedToast()` while an undo toast is visible. The flag auto-clears after the undo duration expires (with a small buffer). - When the user clicks "Undo", re-arm the suppression so the reverse mutation's savedToast is also suppressed. - Fix ProjectSection budget slider undo: use `hadOverride` (captured at commit time) instead of comparing prev value against the global default — the old comparison would incorrectly call clearOverride when a project override happened to equal the global value. - Guard against undefined `serverBudget` in the onValueCommit handler to prevent passing undefined to setLocalBudget or updateOverride. SUSTN-Task: acb368a4-bf78-4c80-b300-b65594ddee3c --- .../settings/sections/ProjectSection.tsx | 11 +++---- src/ui/lib/toast.tsx | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/ui/components/settings/sections/ProjectSection.tsx b/src/ui/components/settings/sections/ProjectSection.tsx index 70bc7ab..ac95230 100644 --- a/src/ui/components/settings/sections/ProjectSection.tsx +++ b/src/ui/components/settings/sections/ProjectSection.tsx @@ -526,7 +526,11 @@ export function ProjectSection({ onValueChange={([value]) => setLocalBudget(value)} onValueCommit={([value]) => { const prev = serverBudget; - if (prev === value) return; + if (prev === undefined || prev === value) + return; + const hadOverride = + overrides.overrideBudgetCeilingPercent !== + undefined; updateOverride({ repositoryId, field: "overrideBudgetCeilingPercent", @@ -534,10 +538,7 @@ export function ProjectSection({ }); undoToast(`Project budget → ${value}%`, () => { setLocalBudget(prev); - if ( - prev === - globalSettings.budgetCeilingPercent - ) { + if (!hadOverride) { clearOverride({ repositoryId, field: "overrideBudgetCeilingPercent", diff --git a/src/ui/lib/toast.tsx b/src/ui/lib/toast.tsx index 67f73ef..3bd6054 100644 --- a/src/ui/lib/toast.tsx +++ b/src/ui/lib/toast.tsx @@ -1,13 +1,35 @@ import { toast } from "sonner"; import { Check, Clock, Undo2 } from "lucide-react"; +/** + * When an undo toast is visible we suppress the normal "Saved" toast so the + * two don't fight for the user's attention. The flag is set when `undoToast` + * fires and cleared after the undo window expires or the user clicks Undo. + */ +let undoActive = false; +let undoTimer: ReturnType | undefined; + +const UNDO_DURATION = 5000; + +function markUndoActive() { + undoActive = true; + clearTimeout(undoTimer); + undoTimer = setTimeout(() => { + undoActive = false; + }, UNDO_DURATION + 500); // small buffer past toast duration +} + /** * Shows a toast with an "Undo" button. If the user clicks Undo within the * duration window, `onUndo` fires and the toast is dismissed. Otherwise the * toast disappears and the change sticks. + * + * While the undo toast is visible, `savedToast()` calls are suppressed so the + * two toasts don't overlap or replace each other. */ export function undoToast(message: string, onUndo: () => void) { const id = "settings-undo"; + markUndoActive(); toast.custom( () => (
@@ -17,6 +39,7 @@ export function undoToast(message: string, onUndo: () => void) {
), - { id, duration: 5000 }, + { id, duration: UNDO_DURATION }, ); } export function savedToast() { + // Don't show "Saved" while an undo toast is active — the undo toast + // already communicates that the change was applied. + if (undoActive) return; + toast.custom( () => (