From 5cc99bfec6e89635d2c8fc88dcf1b9fa090c4b49 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 27 Aug 2026 10:15:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Listen-Einstellungen=20=E2=80=94=20?= =?UTF-8?q?Checkbox=20fuer=20serialize=5Fon=5Ffile=5Foverlap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Flag war bisher nur ueber MCP set_list_config erreichbar. UpdateListConfigDto fuehrt es tri-state (null = gespeicherten Wert behalten), damit nur das Listen-Modal es setzen/loeschen kann und kein anderer Aufrufer es per Omission verliert. --- src/ClaudeDo.Data/Wire.cs | 7 +++-- src/ClaudeDo.Localization/locales/de.json | 2 ++ src/ClaudeDo.Localization/locales/en.json | 2 ++ .../Agent/AgentConfigEditorViewModel.cs | 12 ++++---- .../Modals/ListSettingsModalViewModel.cs | 8 ++++- .../Views/Modals/ListSettingsModalView.axaml | 7 +++++ .../External/ConfigMcpTools.cs | 4 +-- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 14 ++++----- .../Hub/ListConfigHubTests.cs | 29 +++++++++++++++++-- 9 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 653373ef..12013131 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -94,11 +94,14 @@ public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false); -public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null); +// SerializeOnFileOverlap is tri-state on purpose: null = leave the stored flag alone. A caller that +// doesn't own the field (anything but the list-settings modal) must not be able to clear it by +// omission — SetConfigAsync copies the entity verbatim. +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); -public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null); +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false); public record SeedResultDto(int Copied, int Skipped); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index c77dc12b..0cfab45c 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -371,6 +371,8 @@ "manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.", "findingsTracked": "Findings-Ordner .claudedo einchecken", "findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", + "serializeOnFileOverlap": "Tasks mit gleichen Dateien serialisieren", + "serializeOnFileOverlapHint": "Aus: Tasks dieser Liste laufen parallel. An: ein wartender Task, dessen deklarierter Datei-Scope einen laufenden oder review-wartenden Nachbarn überlappt, wartet statt zu starten.", "sectionAgent": "AGENT", "resetAgentSettings": "Agent-Einstellungen zurücksetzen", "sectionVerify": "VERIFIKATION", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 6ceb4f4f..5dbb32dd 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -371,6 +371,8 @@ "manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.", "findingsTracked": "Commit the .claudedo findings folder", "findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", + "serializeOnFileOverlap": "Serialize tasks that touch the same files", + "serializeOnFileOverlapHint": "Off: tasks in this list run in parallel. On: a queued task whose declared file scope overlaps a running or awaiting-review sibling waits instead of starting.", "sectionAgent": "AGENT", "resetAgentSettings": "Reset agent settings", "sectionVerify": "VERIFICATION", diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index ff309e56..493c386a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -189,10 +189,12 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa catch { } } - // verifyCommand is a List-only field owned by ListSettingsModalViewModel (not this editor, - // which is also reused for Task scope); the caller passes it through so the single - // UpdateListConfig call carries the full desired row instead of clobbering it. - public async System.Threading.Tasks.Task SaveAsync(string? verifyCommand = null) + // verifyCommand and serializeOnFileOverlap are List-only fields owned by + // ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller + // passes them through so the single UpdateListConfig call carries the full desired row instead + // of clobbering it. + public async System.Threading.Tasks.Task SaveAsync( + string? verifyCommand = null, bool? serializeOnFileOverlap = null) { if (TargetId is null) return; var model = string.IsNullOrWhiteSpace(Model) ? null : Model; @@ -204,7 +206,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa if (_scope == AgentConfigScope.Task) await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills)); else - await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand)); + await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, serializeOnFileOverlap)); } private List? SelectedSessionSkillNames() diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index cbfcca6b..91c81e7b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -36,6 +36,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase // Optional post-merge verification command (build/test), run in WorkingDir after a merge // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged. [ObservableProperty] private string _verifyCommand = ""; + // When on, the queue picker holds back a queued task whose declared scope globs overlap a + // running/awaiting-merge sibling in this list (ListConfigEntity.SerializeOnFileOverlap). + [ObservableProperty] private bool _serializeOnFileOverlap; public ObservableCollection CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); @@ -71,6 +74,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase await Agent.LoadForListAsync(listId, ct); var cfg = await _worker.GetListConfigAsync(listId); VerifyCommand = cfg?.VerifyCommand ?? ""; + SerializeOnFileOverlap = cfg?.SerializeOnFileOverlap ?? false; } [RelayCommand] @@ -84,7 +88,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase IsManual, FindingsTracked)); - await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand); + await Agent.SaveAsync( + string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, + SerializeOnFileOverlap); CloseAction?.Invoke(); } diff --git a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml index 4e8a659d..5ded0c90 100644 --- a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml @@ -74,6 +74,13 @@ + + + + + diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 440b9f5b..fa8f017b 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -104,8 +104,8 @@ public sealed class ConfigMcpTools // Fields this tool doesn't expose but that live on the same row. They must survive every // write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left - // at its default would silently reset (SerializeOnFileOverlap in particular has no UI - // affordance at all, so a reset is invisible until tasks stop serializing). + // at its default would silently reset (a SerializeOnFileOverlap reset only shows up as + // tasks no longer serializing, long after this write). var hasUnrelatedSettings = existing is not null && (existing.SessionSkills is not null || existing.SerializeOnFileOverlap); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index afcfcbea..c853eb54 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -545,13 +545,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var sessionSkills = SkillsToJson(dto.SessionSkills); var verifyCommand = dto.VerifyCommand.NullIfBlank(); - // Preserve SerializeOnFileOverlap: it has no UI/hub affordance yet (set via - // set_list_config or directly against ListConfigEntity), so a save from this path - // must not silently drop it -- neither by deleting the row nor by overwriting it. + // A null SerializeOnFileOverlap means "leave it as stored" — only the list-settings modal + // owns that field, so every other caller must not drop it (neither by deleting the row nor + // by overwriting it: SetConfigAsync copies the entity verbatim). var existing = await repo.GetConfigAsync(dto.ListId); - var hasUnrelatedSettings = existing?.SerializeOnFileOverlap ?? false; + var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false; - if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && !hasUnrelatedSettings) + if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && !serializeOnFileOverlap) { await repo.DeleteConfigAsync(dto.ListId); } @@ -566,7 +566,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub MaxTurns = dto.MaxTurns, SessionSkills = sessionSkills, VerifyCommand = verifyCommand, - SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false, + SerializeOnFileOverlap = serializeOnFileOverlap, }); } @@ -579,7 +579,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var repo = new ListRepository(ctx); var config = await repo.GetConfigAsync(listId); if (config is null) return null; - return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand); + return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap); } public async Task SetTaskStatus(string taskId, string status) diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs index 4980e8c8..d3a2bc6d 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigHubTests.cs @@ -8,8 +8,8 @@ using Xunit; namespace ClaudeDo.Worker.Tests.Hub; /// UpdateListConfig's "all fields blank -> delete the row" branch used to delete unconditionally, -/// silently dropping SerializeOnFileOverlap -- a flag with no UI/hub affordance of its own (set -/// only via set_list_config or directly against ListConfigEntity). +/// silently dropping SerializeOnFileOverlap. The DTO field is tri-state: the list-settings modal +/// sends an explicit true/false, every other caller sends null and must not clear the stored flag. public sealed class ListConfigHubTests : IDisposable { private readonly DbFixture _db = new(); @@ -98,4 +98,29 @@ public sealed class ListConfigHubTests : IDisposable Assert.Equal("opus", config!.Model); Assert.True(config.SerializeOnFileOverlap); } + + [Fact] + public async Task UpdateListConfig_ExplicitFalse_ClearsFlagAndDeletesOtherwiseEmptyRow() + { + var hub = CreateHub(); + var listId = await SeedListAsync(); + await SeedConfigAsync(listId, serializeOnFileOverlap: true); + + await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: false)); + + Assert.Null(await GetConfigAsync(listId)); + } + + [Fact] + public async Task UpdateListConfig_ExplicitTrue_SetsFlagOnOtherwiseEmptyRow() + { + var hub = CreateHub(); + var listId = await SeedListAsync(); + + await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: true)); + + var config = await GetConfigAsync(listId); + Assert.NotNull(config); + Assert.True(config!.SerializeOnFileOverlap); + } }