Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/ui/components/settings/sections/BudgetSection.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { useState, useEffect } from "react";
import { Switch } from "@ui/components/ui/switch";
import { Slider } from "@ui/components/ui/slider";
import { SettingsRow } from "../SettingsRow";
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<number | undefined>(
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 (
Expand Down Expand Up @@ -44,11 +56,26 @@ export function BudgetSection() {
<Slider
value={[ceiling]}
onValueChange={([value]) =>
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}
Expand Down
64 changes: 57 additions & 7 deletions src/ui/components/settings/sections/ProjectSection.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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<number | undefined>(
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);

Expand Down Expand Up @@ -124,6 +141,7 @@ export function ProjectSection({
const effectiveSchedule =
agentConfig?.scheduleMode ?? (globalSettings.agentMode as ScheduleMode);
const effectiveBudget =
localBudget ??
overrides.overrideBudgetCeilingPercent ??
globalSettings.budgetCeilingPercent;

Expand Down Expand Up @@ -421,13 +439,23 @@ export function ProjectSection({
<button
key={mode.value}
type="button"
onClick={() =>
onClick={() => {
const prev = effectiveSchedule;
if (prev === mode.value) return;
updateAgentConfig({
repositoryId,
scheduleMode:
mode.value as ScheduleMode,
})
}
});
undoToast(
`Schedule → ${mode.label}`,
() =>
updateAgentConfig({
repositoryId,
scheduleMode: prev,
}),
);
}}
className={`group flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-all duration-200 ${
isSelected
? "bg-foreground text-background shadow-sm"
Expand Down Expand Up @@ -495,13 +523,35 @@ export function ProjectSection({
<div className="flex shrink-0 items-center gap-3">
<Slider
value={[effectiveBudget]}
onValueChange={([value]) =>
onValueChange={([value]) => setLocalBudget(value)}
onValueCommit={([value]) => {
const prev = serverBudget;
if (prev === undefined || prev === value)
return;
const hadOverride =
overrides.overrideBudgetCeilingPercent !==
undefined;
updateOverride({
repositoryId,
field: "overrideBudgetCeilingPercent",
value,
})
}
});
undoToast(`Project budget → ${value}%`, () => {
setLocalBudget(prev);
if (!hadOverride) {
clearOverride({
repositoryId,
field: "overrideBudgetCeilingPercent",
});
} else {
updateOverride({
repositoryId,
field: "overrideBudgetCeilingPercent",
value: prev,
});
}
});
}}
min={10}
max={100}
step={5}
Expand Down
17 changes: 14 additions & 3 deletions src/ui/components/settings/sections/SchedulingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ScanFrequency,
} from "@core/types/settings";
import { Clock, Zap, Hand } from "lucide-react";
import { undoToast } from "@ui/lib/toast";

const AGENT_MODES: {
value: AgentMode;
Expand Down Expand Up @@ -112,12 +113,22 @@ export function SchedulingSection() {
<button
key={mode.value}
type="button"
onClick={() =>
onClick={() => {
const prev = settings.agentMode;
if (prev === mode.value) return;
updateSetting({
key: "agentMode",
value: mode.value,
})
}
});
undoToast(
`Agent mode → ${mode.label}`,
() =>
updateSetting({
key: "agentMode",
value: prev,
}),
);
}}
className={`group flex flex-1 flex-col items-start gap-1.5 rounded-md px-3 py-2.5 text-left transition-all duration-200 ${
isSelected
? "bg-foreground text-background shadow-sm"
Expand Down
59 changes: 58 additions & 1 deletion src/ui/lib/toast.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,64 @@
import { toast } from "sonner";
import { Check, Clock } from "lucide-react";
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<typeof setTimeout> | 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(
() => (
<div className="flex items-center gap-2 rounded-full bg-foreground pl-2.5 pr-1 py-1 shadow-md animate-fade-in-up">
<span className="text-[12px] font-medium text-background">
{message}
</span>
<button
type="button"
onClick={() => {
markUndoActive(); // suppress savedToast from the undo mutation too
onUndo();
toast.dismiss(id);
}}
className="flex items-center gap-1 rounded-full bg-background/15 px-2 py-0.5 text-[11px] font-medium text-background hover:bg-background/25 transition-colors"
>
<Undo2 className="h-2.5 w-2.5" />
Undo
</button>
</div>
),
{ 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(
() => (
<div className="flex items-center gap-1.5 rounded-full bg-foreground pl-2 pr-2.5 py-1 shadow-md animate-fade-in-up">
Expand Down