feat(usage): split throttle thresholds per bucket, add draggable gauge markers

This commit is contained in:
mika kuns
2026-08-07 11:01:45 +02:00
parent 231b063751
commit 7eeb8f5086
33 changed files with 2289 additions and 147 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ Beyond the basics it carries:
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. |
| `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. |
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
@@ -55,10 +55,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.UsageGateSevenDayPct)
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
builder.Property(s => s.UsageThrottleSoftPct)
.HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleHardPct)
.HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65);
builder.Property(s => s.UsageThrottleFiveHourSoftPct)
.HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleFiveHourHardPct)
.HasColumnName("usage_throttle_five_hour_hard_pct").IsRequired().HasDefaultValue(65);
builder.Property(s => s.UsageThrottleSevenDaySoftPct)
.HasColumnName("usage_throttle_seven_day_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleSevenDayHardPct)
.HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
}
@@ -0,0 +1,883 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260806141710_SplitUsageThrottlePerBucket")]
partial class SplitUsageThrottlePerBucket
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class SplitUsageThrottlePerBucket : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "usage_throttle_soft_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_hard_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_hard_pct");
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 65);
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 50);
// The old single soft/hard pair was compared against whichever bucket was more utilized,
// so carrying it into BOTH buckets keeps an existing install behaving exactly as before
// the split — the rename above already preserved it for the 7d side.
migrationBuilder.Sql(
"""
UPDATE app_settings
SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct,
usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_soft_pct",
table: "app_settings",
newName: "usage_throttle_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_hard_pct",
table: "app_settings",
newName: "usage_throttle_hard_pct");
}
}
}
@@ -106,17 +106,29 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct")
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct");
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
@@ -155,8 +167,10 @@ namespace ClaudeDo.Data.Migrations
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 50,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
@@ -43,8 +43,11 @@ public sealed class AppSettingsEntity
public int UsageGateFiveHourPct { get; set; } = 80;
public int UsageGateSevenDayPct { get; set; } = 90;
// Percentage of the 5h/7d Claude usage window at which the queue starts throttling
// parallelism ahead of the hard gate above. 0 = that stage off.
public int UsageThrottleSoftPct { get; set; } = 50;
public int UsageThrottleHardPct { get; set; } = 65;
// Percentage at which the queue starts throttling parallelism ahead of the hard gate above.
// Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps
// parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket.
public int UsageThrottleFiveHourSoftPct { get; set; } = 50;
public int UsageThrottleFiveHourHardPct { get; set; } = 65;
public int UsageThrottleSevenDaySoftPct { get; set; } = 50;
public int UsageThrottleSevenDayHardPct { get; set; } = 65;
}
@@ -88,8 +88,10 @@ public sealed class AppSettingsRepository
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100);
row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100);
row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 0, 100);
row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100);
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
await _context.SaveChangesAsync(ct);
}
+6 -1
View File
@@ -433,6 +433,10 @@
"gateBlockedFormat": "Queue pausiert — {0}",
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
"resetIn": "Reset in {0}",
"dragHint": "Marker ziehen oder Wert unten eintragen.",
"legendSoft": "Soft · 2 Slots",
"legendHard": "Hard · 1 Slot",
"legendGate": "Gate · Pause",
"gaugeSession": "Session (5 Std.)",
"gaugeWeeklyAll": "Woche (alle Modelle)",
"gaugeWeeklyScopedFormat": "Woche ({0})",
@@ -657,7 +661,8 @@
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"usageMonitor": {
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}"
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}"
},
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
+6 -1
View File
@@ -433,6 +433,10 @@
"gateBlockedFormat": "Queue paused — {0}",
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
"resetIn": "Reset in {0}",
"dragHint": "Drag a marker, or type the value below.",
"legendSoft": "Soft · 2 slots",
"legendHard": "Hard · 1 slot",
"legendGate": "Gate · pause",
"gaugeSession": "Session (5h)",
"gaugeWeeklyAll": "Week (all models)",
"gaugeWeeklyScopedFormat": "Week ({0})",
@@ -657,7 +661,8 @@
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"usageMonitor": {
"loadFailed": "Couldn't load usage data: {0}",
"refreshFailed": "Couldn't refresh usage: {0}"
"refreshFailed": "Couldn't refresh usage: {0}",
"thresholdSaveFailed": "Couldn't save the threshold: {0}"
},
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
+2 -2
View File
@@ -32,7 +32,7 @@ ViewModels/
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
AgentConfigEditor
AgentConfigEditor, UsagePill, UsageGaugeBar
Design/ — Tokens.axaml (design tokens; merged before styles)
+ IslandStyles.axaml (component styles + the filled icon geometry library)
```
@@ -58,7 +58,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). |
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
+13 -2
View File
@@ -670,7 +670,12 @@ public sealed record AppSettingsDto(
List<ModelPresetDto>? ModelPresets = null,
int UsageGateFiveHourPct = 80,
int UsageGateSevenDayPct = 90,
int MaxTurnsCeiling = 80);
int MaxTurnsCeiling = 80,
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
int UsageThrottleFiveHourSoftPct = 50,
int UsageThrottleFiveHourHardPct = 65,
int UsageThrottleSevenDaySoftPct = 50,
int UsageThrottleSevenDayHardPct = 65);
// Per-model run defaults (effort + turn budget) edited in Settings → General.
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -764,7 +769,13 @@ public sealed record UsageSnapshotDto(
string? LastError,
int ConfiguredSlots,
int EffectiveSlots,
string? ThrottleBucket);
string? ThrottleBucket,
// Throttle stages per bucket, drawn (and dragged) on the usage-monitor gauges. Defaults match
// the DB defaults so an older worker that doesn't send them yet still yields sane markers.
int ThrottleFiveHourSoftPct = 50,
int ThrottleFiveHourHardPct = 65,
int ThrottleSevenDaySoftPct = 50,
int ThrottleSevenDayHardPct = 65);
public sealed record ModelUsageRowDto(
DateOnly Date,
@@ -570,7 +570,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
var vm = _usageMonitorVmFactory();
vm.ErrorReported += FlashFooterError;
await vm.LoadAsync();
// Show first, load after: the initial transcript scan takes seconds, and awaiting it
// here left the pill looking unresponsive until the window finally appeared.
vm.BeginLoad();
await Dialogs.ShowUsageMonitorAsync(vm);
}
finally { _usageMonitorOpen = false; }
@@ -19,6 +19,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
@@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
}
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
// carried through load→save verbatim so saving Settings can never reset a dragged value.
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
public async Task LoadAsync()
{
IsBusy = true;
@@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.MaxParallelExecutions = dto.MaxParallelExecutions;
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
@@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.ModelPresetDtos(),
General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct,
General.MaxTurnsCeiling);
General.MaxTurnsCeiling,
_throttleStages.FiveSoft,
_throttleStages.FiveHard,
_throttleStages.SevenSoft,
_throttleStages.SevenHard);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public event Action<string>? ErrorReported;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(GaugeRows))]
[NotifyPropertyChangedFor(nameof(IsStale))]
[NotifyPropertyChangedFor(nameof(LastError))]
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
@@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows =>
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList();
[ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError;
@@ -83,13 +82,30 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
CloseAction?.Invoke();
}
/// <summary>
/// Starts the load without blocking the caller, so the host can show the window right away and
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
/// window made the usage pill look like it swallowed the click.
/// </summary>
public void BeginLoad() => _ = LoadAsync();
public async Task LoadAsync()
{
Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
IsBusy = true;
try
{
Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
}
finally { IsBusy = false; }
}
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
@@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
finally { IsBusy = false; }
}
partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();
/// <summary>
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
/// while the user works the markers doesn't swap the instances out from under the drag.
/// </summary>
private void SyncGaugeRows()
{
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
var existing = GaugeRows.ToDictionary(r => r.Key);
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
foreach (var limit in limits)
{
var key = GaugeKey(limit);
var bucket = GaugeBucket(limit);
var (soft, hard, gate) = StagesFor(bucket);
var label = BuildGaugeLabel(limit);
if (existing.TryGetValue(key, out var row))
{
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
rows.Add(row);
}
else
{
rows.Add(new UsageGaugeRowViewModel(
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
soft, hard, gate, SaveStagesAsync));
}
}
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
}
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
{
"session" => "five_hour",
"weekly_all" => "seven_day",
_ => null,
};
private static string GaugeKey(UsageLimitDto limit) =>
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
{
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
_ => (null, null, null),
};
/// <summary>
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
/// this never clobbers a field the usage monitor doesn't own.
/// </summary>
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
{
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
return;
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
var updated = row.Bucket == "five_hour"
? settings with
{
UsageThrottleFiveHourSoftPct = soft,
UsageThrottleFiveHourHardPct = hard,
UsageGateFiveHourPct = gate,
}
: settings with
{
UsageThrottleSevenDaySoftPct = soft,
UsageThrottleSevenDayHardPct = hard,
UsageGateSevenDayPct = gate,
};
await _worker.UpdateAppSettingsAsync(updated);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
}
}
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
{
"session" => Loc.T("modals.usageMonitor.gaugeSession"),
@@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
_ => limit.Kind,
};
private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit)
{
int? threshold = limit.Kind switch
{
"session" => Snapshot?.FiveHourThresholdPct,
"weekly_all" => Snapshot?.SevenDayThresholdPct,
_ => null,
};
return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold);
}
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
{
var built = new List<ModelUsageDisplayRow>();
@@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
}
}
public sealed record UsageGaugeRowViewModel(
string Label,
double Percent,
string Severity,
DateTimeOffset? ResetsAt,
int? ThresholdPercent)
/// <summary>
/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
/// </summary>
public sealed partial class UsageGaugeRowViewModel : ObservableObject
{
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
public UsageGaugeRowViewModel(
string key,
string? bucket,
string label,
double percent,
string severity,
DateTimeOffset? resetsAt,
int? softPct,
int? hardPct,
int? gatePct,
Func<UsageGaugeRowViewModel, Task>? commit = null)
{
Key = key;
Bucket = bucket;
_label = label;
_percent = percent;
_severity = severity;
_resetsAt = resetsAt;
_softPct = softPct;
_hardPct = hardPct;
_gatePct = gatePct;
_commit = commit;
}
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
public string Key { get; }
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
public string? Bucket { get; }
[ObservableProperty] private string _label;
[ObservableProperty] private double _percent;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
private string _severity;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ResetText))]
private DateTimeOffset? _resetsAt;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _softPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _hardPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _gatePct;
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
// Matches the gauge card's inner track width in the view (240 card width - 12*2 padding).
private const double GaugeTrackWidthPx = 216;
/// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
int? softPct, int? hardPct, int? gatePct)
{
Label = label;
Percent = percent;
Severity = severity;
ResetsAt = resetsAt;
SoftPct = softPct;
HardPct = hardPct;
GatePct = gatePct;
}
public double ThresholdMarkerLeftPx =>
ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0;
/// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
[RelayCommand]
private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;
// One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
// can't invert the order — and only the edited stage moves, never its neighbours.
[RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
[RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
[RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);
private Task CommitStage(UsageThresholdDrag.Stage stage)
{
if (!IsAdjustable) return Task.CompletedTask;
var edited = stage switch
{
UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
UsageThresholdDrag.Stage.Hard => HardPct!.Value,
_ => GatePct!.Value,
};
var (soft, hard, gate) = UsageThresholdDrag.Apply(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
SoftPct = soft;
HardPct = hard;
GatePct = gate;
return Commit();
}
private static string FormatRemaining(DateTimeOffset resetsAt)
{
@@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel(
}
}
/// <summary>
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
/// at 0 means "that stage is off" and therefore does not constrain anything.
/// </summary>
public static class UsageThresholdDrag
{
public enum Stage { Soft, Hard, Gate }
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
{
Stage? best = null;
var bestDistance = double.MaxValue;
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
{
var distance = Math.Abs(percent - value);
if (distance > tolerancePercent || distance >= bestDistance) continue;
best = stage;
bestDistance = distance;
}
return best;
}
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
{
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
return stage switch
{
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
_ => (soft, hard, gate),
};
}
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
private static int UpperBound(int nearest, int fallback) =>
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
// An already-inconsistent stored config (min above max) must not throw mid-drag.
private static int ClampRange(int value, int min, int max) =>
max < min ? max : Math.Clamp(value, min, max);
}
public sealed record ModelUsageDisplayRow(
string Model,
long ClaudeDoInputTokens,
@@ -0,0 +1,250 @@
using System;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Rendering;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate
/// (queue paused). Positions are computed against the control's real width — no hardcoded track
/// size — and the drag math lives in <see cref="UsageThresholdDrag"/> so it stays testable.
/// Values are written back through TwoWay bindings while dragging; <see cref="CommitCommand"/>
/// fires once on release, which is when the host persists them.
/// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar.
/// </summary>
public sealed class UsageGaugeBar : Control, ICustomHitTest
{
/// <summary>How close the pointer has to be to grab a marker.</summary>
private const double GrabRadiusPx = 12;
private const double TrackHeightPx = 10;
private const double MarkerWidthPx = 2;
public static readonly StyledProperty<double> PercentProperty =
AvaloniaProperty.Register<UsageGaugeBar, double>(nameof(Percent));
public static readonly StyledProperty<bool> IsWarnProperty =
AvaloniaProperty.Register<UsageGaugeBar, bool>(nameof(IsWarn));
public static readonly StyledProperty<int?> SoftPctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<int?> HardPctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(HardPct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<int?> GatePctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(GatePct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<IBrush?> TrackBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(TrackBrush));
public static readonly StyledProperty<IBrush?> FillBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(FillBrush));
public static readonly StyledProperty<IBrush?> WarnFillBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(WarnFillBrush));
public static readonly StyledProperty<IBrush?> SoftMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(SoftMarkerBrush));
public static readonly StyledProperty<IBrush?> HardMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(HardMarkerBrush));
public static readonly StyledProperty<IBrush?> GateMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(GateMarkerBrush));
public static readonly StyledProperty<ICommand?> CommitCommandProperty =
AvaloniaProperty.Register<UsageGaugeBar, ICommand?>(nameof(CommitCommand));
static UsageGaugeBar()
{
AffectsRender<UsageGaugeBar>(
PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty,
TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty,
SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty);
}
private UsageThresholdDrag.Stage? _dragging;
public double Percent
{
get => GetValue(PercentProperty);
set => SetValue(PercentProperty, value);
}
public bool IsWarn
{
get => GetValue(IsWarnProperty);
set => SetValue(IsWarnProperty, value);
}
public int? SoftPct
{
get => GetValue(SoftPctProperty);
set => SetValue(SoftPctProperty, value);
}
public int? HardPct
{
get => GetValue(HardPctProperty);
set => SetValue(HardPctProperty, value);
}
public int? GatePct
{
get => GetValue(GatePctProperty);
set => SetValue(GatePctProperty, value);
}
public IBrush? TrackBrush
{
get => GetValue(TrackBrushProperty);
set => SetValue(TrackBrushProperty, value);
}
public IBrush? FillBrush
{
get => GetValue(FillBrushProperty);
set => SetValue(FillBrushProperty, value);
}
public IBrush? WarnFillBrush
{
get => GetValue(WarnFillBrushProperty);
set => SetValue(WarnFillBrushProperty, value);
}
public IBrush? SoftMarkerBrush
{
get => GetValue(SoftMarkerBrushProperty);
set => SetValue(SoftMarkerBrushProperty, value);
}
public IBrush? HardMarkerBrush
{
get => GetValue(HardMarkerBrushProperty);
set => SetValue(HardMarkerBrushProperty, value);
}
public IBrush? GateMarkerBrush
{
get => GetValue(GateMarkerBrushProperty);
set => SetValue(GateMarkerBrushProperty, value);
}
public ICommand? CommitCommand
{
get => GetValue(CommitCommandProperty);
set => SetValue(CommitCommandProperty, value);
}
private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null;
// Custom hit test (point is in local coordinates): the control draws itself, so the whole
// rectangle takes the pointer — not just the pixels the track happens to cover.
public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point);
public override void Render(DrawingContext context)
{
var width = Bounds.Width;
var height = Bounds.Height;
if (width <= 0 || height <= 0) return;
var top = Math.Max(0, (height - TrackHeightPx) / 2);
var trackHeight = Math.Min(TrackHeightPx, height);
var radius = trackHeight / 2;
// Transparent full-bounds fill keeps the grab area the whole control, not just the track.
context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height));
if (TrackBrush is { } track)
context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius));
var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0;
var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush;
if (fillWidth > 0 && fill is not null)
context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius));
DrawMarker(context, SoftPct, SoftMarkerBrush, width, height);
DrawMarker(context, HardPct, HardMarkerBrush, width, height);
DrawMarker(context, GatePct, GateMarkerBrush, width, height);
}
private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height)
{
if (percent is not { } value || brush is null) return;
var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx));
context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height));
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (!IsAdjustable) return;
var percent = PercentAt(e.GetPosition(this).X);
_dragging = UsageThresholdDrag.Nearest(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
if (_dragging is null) return;
e.Pointer.Capture(this);
ApplyDrag(_dragging.Value, percent);
e.Handled = true;
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
if (!IsAdjustable) return;
var percent = PercentAt(e.GetPosition(this).X);
if (_dragging is { } stage)
{
ApplyDrag(stage, percent);
e.Handled = true;
return;
}
var hover = UsageThresholdDrag.Nearest(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_dragging is null) return;
_dragging = null;
e.Pointer.Capture(null);
e.Handled = true;
if (CommitCommand is { } command && command.CanExecute(null))
command.Execute(null);
}
private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent)
{
var (soft, hard, gate) = UsageThresholdDrag.Apply(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent);
SoftPct = soft;
HardPct = hard;
GatePct = gate;
}
private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100);
private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0;
}
@@ -390,7 +390,8 @@
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}"
IsVisible="{Binding ShowOnlineInbox}">
<ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0">
@@ -8,6 +8,7 @@ public partial class SettingsModalView : Window
public SettingsModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
@@ -18,19 +18,6 @@
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="ProgressBar.usage-gauge">
<Setter Property="Height" Value="10"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
</Style>
<Style Selector="ProgressBar.usage-gauge.warn">
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
<DockPanel>
@@ -82,19 +69,60 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240">
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
</StackPanel>
<Grid Height="10">
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/>
<Canvas IsHitTestVisible="False">
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10"
Fill="{DynamicResource TextDimBrush}"
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
</Canvas>
<ctl:UsageGaugeBar Height="16"
Percent="{Binding Percent}"
IsWarn="{Binding IsWarnSeverity}"
SoftPct="{Binding SoftPct, Mode=TwoWay}"
HardPct="{Binding HardPct, Mode=TwoWay}"
GatePct="{Binding GatePct, Mode=TwoWay}"
CommitCommand="{Binding CommitCommand}"
TrackBrush="{DynamicResource LineBrush}"
FillBrush="{DynamicResource AccentBrush}"
WarnFillBrush="{DynamicResource StatusReviewBrush}"
SoftMarkerBrush="{DynamicResource TextDimBrush}"
HardMarkerBrush="{DynamicResource StatusReviewBrush}"
GateMarkerBrush="{DynamicResource StatusErrorBrush}"
ToolTip.Tip="{loc:Tr modals.usageMonitor.dragHint}"/>
<!-- Legend doubles as the numeric editor: swatch colours match the bar's markers,
and each box commits on Enter / focus loss (handlers in the code-behind). -->
<Grid ColumnDefinitions="10,*,62" RowDefinitions="Auto,Auto,Auto"
IsVisible="{Binding IsAdjustable}" Margin="0,2,0,0">
<Rectangle Grid.Row="0" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource TextDimBrush}"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendSoft}"/>
<NumericUpDown Grid.Row="0" Grid.Column="2" Tag="soft"
Value="{Binding SoftPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="1" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusReviewBrush}"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendHard}"/>
<NumericUpDown Grid.Row="1" Grid.Column="2" Tag="hard"
Value="{Binding HardPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="2" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusErrorBrush}"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendGate}"/>
<NumericUpDown Grid.Row="2" Grid.Column="2" Tag="gate"
Value="{Binding GatePct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
</Grid>
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
@@ -1,8 +1,36 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class UsageMonitorModalView : Window
{
public UsageMonitorModalView() => InitializeComponent();
/// <summary>
/// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so
/// the box's <c>Tag</c> names the stage and the row's matching command does the clamp + save.
/// </summary>
private void OnStageBoxCommit(object? sender, RoutedEventArgs e)
{
if (sender is not Control { Tag: string stage, DataContext: UsageGaugeRowViewModel row }) return;
var command = stage switch
{
"soft" => row.CommitSoftCommand,
"hard" => row.CommitHardCommand,
_ => row.CommitGateCommand,
};
if (command.CanExecute(null)) command.Execute(null);
}
private void OnStageBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
OnStageBoxCommit(sender, e);
e.Handled = true;
}
}
+3 -1
View File
@@ -62,7 +62,9 @@ public sealed class WindowDialogService : IDialogService
{
var dlg = new UsageMonitorModalView { DataContext = vm };
vm.CloseAction = () => dlg.Close();
await dlg.ShowDialog(_owner);
// The pill sits in both the footer and the Mission Control header, so own the dialog to
// whichever window is active — otherwise it opens behind Mission Control.
await dlg.ShowDialog(ActiveOwner());
}
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
+21 -3
View File
@@ -50,7 +50,12 @@ public record AppSettingsDto(
List<ModelPresetDto>? ModelPresets = null,
int UsageGateFiveHourPct = 80,
int UsageGateSevenDayPct = 90,
int MaxTurnsCeiling = 80);
int MaxTurnsCeiling = 80,
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
int UsageThrottleFiveHourSoftPct = 50,
int UsageThrottleFiveHourHardPct = 65,
int UsageThrottleSevenDaySoftPct = 50,
int UsageThrottleSevenDayHardPct = 65);
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -137,7 +142,12 @@ public record UsageSnapshotDto(
string? LastError,
int ConfiguredSlots,
int EffectiveSlots,
string? ThrottleBucket);
string? ThrottleBucket,
// Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge.
int ThrottleFiveHourSoftPct,
int ThrottleFiveHourHardPct,
int ThrottleSevenDaySoftPct,
int ThrottleSevenDayHardPct);
public record ModelUsageRowDto(
DateOnly Date,
@@ -446,7 +456,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
row.UsageGateFiveHourPct,
row.UsageGateSevenDayPct,
row.MaxTurnsCeiling);
row.MaxTurnsCeiling,
row.UsageThrottleFiveHourSoftPct,
row.UsageThrottleFiveHourHardPct,
row.UsageThrottleSevenDaySoftPct,
row.UsageThrottleSevenDayHardPct);
}
public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
MaxTurnsCeiling = dto.MaxTurnsCeiling,
UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct,
UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct,
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
});
}
+10 -7
View File
@@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService
public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct)
{
int configured;
int softPct, hardPct, gateFivePct, gateSevenPct;
UsageThresholds fiveHour, sevenDay;
try
{
using var context = _dbFactory.CreateDbContext();
var settings = await new AppSettingsRepository(context).GetAsync(ct);
configured = Math.Max(1, settings.MaxParallelExecutions);
softPct = settings.UsageThrottleSoftPct;
hardPct = settings.UsageThrottleHardPct;
gateFivePct = settings.UsageGateFiveHourPct;
gateSevenPct = settings.UsageGateSevenDayPct;
fiveHour = new UsageThresholds(
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct,
settings.UsageGateFiveHourPct);
sevenDay = new UsageThresholds(
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct,
settings.UsageGateSevenDayPct);
}
catch (Exception ex)
{
@@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService
}
var effective = UsageThrottle.EffectiveSlots(
configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
softPct, hardPct, gateFivePct, gateSevenPct);
configured,
snapshot.FiveHour?.Utilization, fiveHour,
snapshot.SevenDay?.Utilization, sevenDay);
ReportThrottleTransition(configured, effective, snapshot);
return (configured, effective);
@@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
if (Directory.Exists(_projectsRoot))
{
foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
// A transcript last written before the window began cannot hold a record inside it, so
// it is skipped unread — that is what keeps a 7-day range off the full history (hundreds
// of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps.
var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1);
foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
if (file.LastWriteTime < mtimeCutoff) continue;
foreach (var record in GetOrReadFile(file))
{
@@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
return Task.FromResult<SessionUsageTotals?>(null);
var file = Directory
.EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories)
var file = new DirectoryInfo(_projectsRoot)
.EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories)
.FirstOrDefault();
if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
@@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
new SessionUsageTotals(input, output, cacheRead, cacheCreation));
}
private List<UsageMessageRecord> GetOrReadFile(string file)
private List<UsageMessageRecord> GetOrReadFile(FileInfo info)
{
var info = new FileInfo(file);
if (_cache.TryGetValue(file, out var cached) &&
if (_cache.TryGetValue(info.FullName, out var cached) &&
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
{
return cached.Records;
}
var records = ReadFile(file);
_cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
var records = ReadFile(info.FullName);
_cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
return records;
}
@@ -49,13 +49,18 @@ public sealed class UsageSnapshotBuilder
.Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
.ToList();
var fiveHourThresholds = new UsageThresholds(
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct);
var sevenDayThresholds = new UsageThresholds(
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct);
var configuredSlots = Math.Max(1, settings.MaxParallelExecutions);
var effectiveSlots = snapshot is null || lastError is not null
? configuredSlots
: UsageThrottle.EffectiveSlots(
configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct,
settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct);
configuredSlots,
snapshot.FiveHour?.Utilization, fiveHourThresholds,
snapshot.SevenDay?.Utilization, sevenDayThresholds);
var throttleBucket = effectiveSlots < configuredSlots
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
: null;
@@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder
lastError,
configuredSlots,
effectiveSlots,
throttleBucket);
throttleBucket,
fiveHourThresholds.SoftPct,
fiveHourThresholds.HardPct,
sevenDayThresholds.SoftPct,
sevenDayThresholds.HardPct);
}
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
+24 -19
View File
@@ -1,37 +1,42 @@
namespace ClaudeDo.Worker.Usage;
/// <summary>
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as the 5h/7d usage
/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of
/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more
/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only —
/// callers with no snapshot at all should skip this and use <paramref name="configuredSlots"/>
/// directly (fail-open), same policy as <see cref="UsageGate"/>.
/// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard
/// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage.
/// </summary>
public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct);
/// <summary>
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as a usage window
/// fills up, the queue's effective parallelism steps down before it hits zero, instead of running
/// at full tilt right up to the gate threshold. Each bucket carries its own thresholds (the 5h and
/// 7d windows fill at very different rates) and the strictest bucket decides. A missing bucket
/// (null) never throttles — callers with no snapshot at all should skip this and use
/// <paramref name="configuredSlots"/> directly (fail-open), same policy as <see cref="UsageGate"/>.
/// </summary>
public static class UsageThrottle
{
public static int EffectiveSlots(
int configuredSlots,
double? fiveHourPct,
UsageThresholds fiveHour,
double? sevenDayPct,
int softPct,
int hardPct,
int gateFiveHourPct,
int gateSevenDayPct)
UsageThresholds sevenDay)
{
var slots = Math.Max(1, configuredSlots);
if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct)
return 0;
if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct)
return 0;
return Math.Min(
BucketSlots(slots, fiveHourPct, fiveHour),
BucketSlots(slots, sevenDayPct, sevenDay));
}
var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0);
private static int BucketSlots(int slots, double? pct, UsageThresholds thresholds)
{
if (pct is not { } utilization) return slots;
if (hardPct > 0 && worst >= hardPct)
return Math.Min(slots, 1);
if (softPct > 0 && worst >= softPct)
return Math.Min(slots, 2);
if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0;
if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1);
if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2);
return slots;
}