feat(ui): Listen-Einstellungen — Checkbox fuer serialize_on_file_overlap

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.
This commit is contained in:
mika kuns
2026-08-27 10:15:17 +02:00
parent 24e1d648ee
commit 5cc99bfec6
9 changed files with 66 additions and 19 deletions
+5 -2
View File
@@ -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 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<string>? 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<string>? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false);
public record SeedResultDto(int Copied, int Skipped); public record SeedResultDto(int Copied, int Skipped);
@@ -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.", "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", "findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", "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", "sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen", "resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION", "sectionVerify": "VERIFIKATION",
@@ -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.", "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", "findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", "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", "sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings", "resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION", "sectionVerify": "VERIFICATION",
@@ -189,10 +189,12 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { } catch { }
} }
// verifyCommand is a List-only field owned by ListSettingsModalViewModel (not this editor, // verifyCommand and serializeOnFileOverlap are List-only fields owned by
// which is also reused for Task scope); the caller passes it through so the single // ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
// UpdateListConfig call carries the full desired row instead of clobbering it. // passes them through so the single UpdateListConfig call carries the full desired row instead
public async System.Threading.Tasks.Task SaveAsync(string? verifyCommand = null) // of clobbering it.
public async System.Threading.Tasks.Task SaveAsync(
string? verifyCommand = null, bool? serializeOnFileOverlap = null)
{ {
if (TargetId is null) return; if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model; var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -204,7 +206,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task) if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills)); await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
else 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<string>? SelectedSessionSkillNames() private List<string>? SelectedSessionSkillNames()
@@ -36,6 +36,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Optional post-merge verification command (build/test), run in WorkingDir after a merge // 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. // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = ""; [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<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
@@ -71,6 +74,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
await Agent.LoadForListAsync(listId, ct); await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId); var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? ""; VerifyCommand = cfg?.VerifyCommand ?? "";
SerializeOnFileOverlap = cfg?.SerializeOnFileOverlap ?? false;
} }
[RelayCommand] [RelayCommand]
@@ -84,7 +88,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
IsManual, IsManual,
FindingsTracked)); FindingsTracked));
await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand); await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
SerializeOnFileOverlap);
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
@@ -74,6 +74,13 @@
<TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}" <TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding SerializeOnFileOverlap, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.serializeOnFileOverlap}"/>
<TextBlock Text="{loc:Tr modals.listSettings.serializeOnFileOverlapHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>
+2 -2
View File
@@ -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 // 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 // write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left
// at its default would silently reset (SerializeOnFileOverlap in particular has no UI // at its default would silently reset (a SerializeOnFileOverlap reset only shows up as
// affordance at all, so a reset is invisible until tasks stop serializing). // tasks no longer serializing, long after this write).
var hasUnrelatedSettings = existing is not null var hasUnrelatedSettings = existing is not null
&& (existing.SessionSkills is not null || existing.SerializeOnFileOverlap); && (existing.SessionSkills is not null || existing.SerializeOnFileOverlap);
+7 -7
View File
@@ -545,13 +545,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var sessionSkills = SkillsToJson(dto.SessionSkills); var sessionSkills = SkillsToJson(dto.SessionSkills);
var verifyCommand = dto.VerifyCommand.NullIfBlank(); var verifyCommand = dto.VerifyCommand.NullIfBlank();
// Preserve SerializeOnFileOverlap: it has no UI/hub affordance yet (set via // A null SerializeOnFileOverlap means "leave it as stored" — only the list-settings modal
// set_list_config or directly against ListConfigEntity), so a save from this path // owns that field, so every other caller must not drop it (neither by deleting the row nor
// must not silently drop it -- neither by deleting the row nor by overwriting it. // by overwriting it: SetConfigAsync copies the entity verbatim).
var existing = await repo.GetConfigAsync(dto.ListId); 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); await repo.DeleteConfigAsync(dto.ListId);
} }
@@ -566,7 +566,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
MaxTurns = dto.MaxTurns, MaxTurns = dto.MaxTurns,
SessionSkills = sessionSkills, SessionSkills = sessionSkills,
VerifyCommand = verifyCommand, 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 repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId); var config = await repo.GetConfigAsync(listId);
if (config is null) return null; 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<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status) public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -8,8 +8,8 @@ using Xunit;
namespace ClaudeDo.Worker.Tests.Hub; namespace ClaudeDo.Worker.Tests.Hub;
/// UpdateListConfig's "all fields blank -> delete the row" branch used to delete unconditionally, /// 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 /// silently dropping SerializeOnFileOverlap. The DTO field is tri-state: the list-settings modal
/// only via set_list_config or directly against ListConfigEntity). /// sends an explicit true/false, every other caller sends null and must not clear the stored flag.
public sealed class ListConfigHubTests : IDisposable public sealed class ListConfigHubTests : IDisposable
{ {
private readonly DbFixture _db = new(); private readonly DbFixture _db = new();
@@ -98,4 +98,29 @@ public sealed class ListConfigHubTests : IDisposable
Assert.Equal("opus", config!.Model); Assert.Equal("opus", config!.Model);
Assert.True(config.SerializeOnFileOverlap); 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);
}
} }