feat(ui): live task updates from worker events + planning polish

Wire TasksIslandViewModel to TaskUpdated/WorktreeUpdated/TaskMessage worker
events so rows refresh without a full reload; add ForegroundHelper to permit
wt.exe to take foreground on planning launch; misc UI polish on lists, task
rows and settings modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-24 11:12:27 +02:00
parent e455d85578
commit b7c60f5838
18 changed files with 200 additions and 56 deletions

View File

@@ -1,6 +1,4 @@
using System; using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -10,8 +8,6 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260416064948_InitialCreate")]
public partial class InitialCreate : Migration public partial class InitialCreate : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,5 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -7,8 +5,6 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260420075929_AddTaskFlagsAndNotes")]
public partial class AddTaskFlagsAndNotes : Migration public partial class AddTaskFlagsAndNotes : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,14 +1,10 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260421113614_AddAppSettings")]
public partial class AddAppSettings : Migration public partial class AddAppSettings : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,5 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -7,8 +5,6 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260422120000_AddTaskSortOrder")]
public partial class AddTaskSortOrder : Migration public partial class AddTaskSortOrder : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,15 +1,10 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260423154708_AddPlanningSupport")]
public partial class AddPlanningSupport : Migration public partial class AddPlanningSupport : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -0,0 +1,19 @@
using System.Runtime.InteropServices;
namespace ClaudeDo.Ui.Services;
internal static class ForegroundHelper
{
private const int ASFW_ANY = -1;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
// Grants any process the right to take foreground on next SetForegroundWindow call.
// Used before RPCs that cause a helper process (e.g. wt.exe) to spawn a new window.
public static void AllowAny()
{
if (!OperatingSystem.IsWindows()) return;
try { AllowSetForegroundWindow(ASFW_ANY); } catch { }
}
}

View File

@@ -2,6 +2,10 @@ namespace ClaudeDo.Ui.Services;
public interface IWorkerClient public interface IWorkerClient
{ {
event Action<string>? TaskUpdatedEvent;
event Action<string>? WorktreeUpdatedEvent;
event Action<string, string>? TaskMessageEvent;
Task WakeQueueAsync(); Task WakeQueueAsync();
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);

View File

@@ -5,8 +5,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class ListNavItemViewModel : ViewModelBase public sealed partial class ListNavItemViewModel : ViewModelBase
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Name { get; init; }
public required ListKind Kind { get; init; } public required ListKind Kind { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private int _count; [ObservableProperty] private int _count;
[ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir; [ObservableProperty] private string? _workingDir;

View File

@@ -40,8 +40,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row) private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
{ {
if (row is null || ShowListSettingsModal is null || _services is null) return; if (row is null || ShowListSettingsModal is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(row.Id, row.Name, row.WorkingDir, row.DefaultCommitType); await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
await ShowListSettingsModal(vm); await ShowListSettingsModal(vm);
await RefreshRowAsync(row.Id); await RefreshRowAsync(row.Id);
} }
@@ -169,6 +170,46 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void Select(ListNavItemViewModel item) => SelectedList = item; private void Select(ListNavItemViewModel item) => SelectedList = item;
[RelayCommand]
private async Task CreateListAsync()
{
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = "New list",
DefaultCommitType = "chore",
CreatedAt = DateTime.UtcNow,
};
await using (var ctx = await _dbFactory.CreateDbContextAsync())
{
var lists = new ListRepository(ctx);
await lists.AddAsync(entity);
}
var item = new ListNavItemViewModel
{
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = "Moss",
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
};
Items.Add(item);
UserLists.Add(item);
SelectedList = item;
if (ShowListSettingsModal is not null && _services is not null)
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
await ShowListSettingsModal(vm);
await RefreshRowAsync(item.Id);
}
}
partial void OnSelectedListChanged(ListNavItemViewModel? value) partial void OnSelectedListChanged(ListNavItemViewModel? value)
{ {
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value); foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
@@ -188,6 +229,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
var entity = await lists.GetByIdAsync(rawId); var entity = await lists.GetByIdAsync(rawId);
if (entity is null) return; if (entity is null) return;
row.Name = entity.Name;
row.WorkingDir = entity.WorkingDir; row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType; row.DefaultCommitType = entity.DefaultCommitType;
} }

View File

@@ -101,25 +101,27 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); } partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
public static TaskRowViewModel FromEntity(TaskEntity t) public static TaskRowViewModel FromEntity(TaskEntity t)
{
var row = new TaskRowViewModel { Id = t.Id, CreatedAt = t.CreatedAt };
row.UpdateFromEntity(t);
return row;
}
public void UpdateFromEntity(TaskEntity t)
{ {
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat); var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
return new TaskRowViewModel Title = t.Title;
{ ListName = t.List?.Name ?? "";
Id = t.Id, Done = t.Status == TaskStatus.Done;
Title = t.Title, IsStarred = t.IsStarred;
ListName = t.List?.Name ?? "", IsMyDay = t.IsMyDay;
Done = t.Status == TaskStatus.Done, Status = t.Status;
IsStarred = t.IsStarred, Branch = t.Worktree?.BranchName;
IsMyDay = t.IsMyDay, DiffStat = t.Worktree?.DiffStat;
Status = t.Status, ScheduledFor = t.ScheduledFor;
Branch = t.Worktree?.BranchName, DiffAdditions = add;
DiffStat = t.Worktree?.DiffStat, DiffDeletions = del;
ScheduledFor = t.ScheduledFor, ParentTaskId = t.ParentTaskId;
DiffAdditions = add,
DiffDeletions = del,
CreatedAt = t.CreatedAt,
ParentTaskId = t.ParentTaskId,
};
} }
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions". // Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".

View File

@@ -49,6 +49,70 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
if (_worker is not null)
{
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
}
}
private void OnWorkerTaskMessage(string taskId, string line)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.LiveTail = line;
}
private async void OnWorkerTaskUpdated(string taskId)
{
var list = _currentList;
if (list is null) return;
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
Regroup();
UpdateSubtitle();
}
catch { }
}
private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch
{
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
ListKind.Virtual when list.Id == "virtual:queued" => t.Status == TaskStatus.Queued,
ListKind.Virtual when list.Id == "virtual:running" => t.Status == TaskStatus.Running,
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active,
ListKind.User => $"user:{t.ListId}" == list.Id,
_ => false,
};
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm)
HeaderTitle = vm.Name;
} }
public void LoadForList(ListNavItemViewModel? list) public void LoadForList(ListNavItemViewModel? list)
@@ -58,7 +122,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_loadCts = new CancellationTokenSource(); _loadCts = new CancellationTokenSource();
var ct = _loadCts.Token; var ct = _loadCts.Token;
if (_currentList is not null)
_currentList.PropertyChanged -= OnCurrentListPropertyChanged;
_currentList = list; _currentList = list;
if (_currentList is not null)
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
Items.Clear(); Items.Clear();
OverdueItems.Clear(); OverdueItems.Clear();
OpenItems.Clear(); OpenItems.Clear();
@@ -385,6 +454,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row) private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
{ {
if (row is null || row.Status != TaskStatus.Manual) return; if (row is null || row.Status != TaskStatus.Manual) return;
ForegroundHelper.AllowAny();
try { await _worker!.StartPlanningSessionAsync(row.Id); } try { await _worker!.StartPlanningSessionAsync(row.Id); }
catch { } catch { }
} }
@@ -412,6 +482,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
switch (choice) switch (choice)
{ {
case UnfinishedPlanningModalResult.Resume: case UnfinishedPlanningModalResult.Resume:
ForegroundHelper.AllowAny();
await _worker.ResumePlanningSessionAsync(row.Id); await _worker.ResumePlanningSessionAsync(row.Id);
break; break;
case UnfinishedPlanningModalResult.FinalizeNow: case UnfinishedPlanningModalResult.FinalizeNow:

View File

@@ -166,7 +166,8 @@
</ItemsControl> </ItemsControl>
<!-- + New list button --> <!-- + New list button -->
<Button Classes="new-list-btn" Margin="0,4,0,0"> <Button Classes="new-list-btn" Margin="0,4,0,0"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}" <PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13" Width="13" Height="13"

View File

@@ -39,16 +39,13 @@
Click="OnRemoveFromQueueClick"/> Click="OnRemoveFromQueueClick"/>
<Separator/> <Separator/>
<MenuItem Header="Open planning Session" <MenuItem Header="Open planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}" Click="OnOpenPlanningSessionClick"
CommandParameter="{Binding}"
IsVisible="{Binding CanOpenPlanningSession}"/> IsVisible="{Binding CanOpenPlanningSession}"/>
<MenuItem Header="Resume planning Session" <MenuItem Header="Resume planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}" Click="OnResumePlanningSessionClick"
CommandParameter="{Binding}"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/> IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="Discard planning session" <MenuItem Header="Discard planning session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}" Click="OnDiscardPlanningSessionClick"
CommandParameter="{Binding}"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/> IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<Separator/> <Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/> <MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>

View File

@@ -39,6 +39,24 @@ public partial class TaskRowView : UserControl
await vm.ClearScheduleCommand.ExecuteAsync(row); await vm.ClearScheduleCommand.ExecuteAsync(row);
} }
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
}
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ResumePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnDiscardPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
}
private void OnScheduleForClick(object? sender, RoutedEventArgs e) private void OnScheduleForClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not TaskRowViewModel row) return; if (DataContext is not TaskRowViewModel row) return;

View File

@@ -14,6 +14,7 @@
<Window.KeyBindings> <Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/> <KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
<KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Window.Styles> <Window.Styles>
@@ -82,7 +83,7 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Name"/> <TextBlock Classes="field-label" Text="Name"/>
<TextBox Text="{Binding Name}" /> <TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">

View File

@@ -13,7 +13,7 @@ namespace ClaudeDo.Worker.Planning;
public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
{ {
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill"; private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
private const string Model = "claude-sonnet-4-6"; private const string Model = "claude-opus-4-7";
private readonly string _wtPath; private readonly string _wtPath;
private readonly string _claudePath; private readonly string _claudePath;
@@ -49,17 +49,19 @@ public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
psi.Environment["MAX_THINKING_TOKENS"] = "20000"; psi.Environment["MAX_THINKING_TOKENS"] = "20000";
// Arg order matters: --allowedTools and --mcp-config are variadic (space-separated).
// The positional prompt must follow a single-value flag, or it will be swallowed.
psi.ArgumentList.Add("-d"); psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir); psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude); psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--model"); psi.ArgumentList.Add("--model");
psi.ArgumentList.Add(Model); psi.ArgumentList.Add(Model);
psi.ArgumentList.Add("--allowedTools");
psi.ArgumentList.Add(AllowedTools);
psi.ArgumentList.Add("--mcp-config"); psi.ArgumentList.Add("--mcp-config");
psi.ArgumentList.Add(ctx.Files.McpConfigPath); psi.ArgumentList.Add(ctx.Files.McpConfigPath);
psi.ArgumentList.Add("--append-system-prompt-file"); psi.ArgumentList.Add("--append-system-prompt-file");
psi.ArgumentList.Add(ctx.Files.SystemPromptPath); psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
psi.ArgumentList.Add("--allowedTools");
psi.ArgumentList.Add(AllowedTools);
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath)); psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
var proc = Process.Start(psi) var proc = Process.Start(psi)
@@ -91,10 +93,10 @@ public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
psi.ArgumentList.Add("-d"); psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir); psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude); psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--resume");
psi.ArgumentList.Add(ctx.ClaudeSessionId);
psi.ArgumentList.Add("--mcp-config"); psi.ArgumentList.Add("--mcp-config");
psi.ArgumentList.Add(ctx.McpConfigPath); psi.ArgumentList.Add(ctx.McpConfigPath);
psi.ArgumentList.Add("--resume");
psi.ArgumentList.Add(ctx.ClaudeSessionId);
var proc = Process.Start(psi) var proc = Process.Start(psi)
?? throw new PlanningLaunchException("Failed to start Windows Terminal process."); ?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");

View File

@@ -60,7 +60,8 @@ builder.Services.AddSingleton(sp =>
new PlanningSessionManager( new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
planningSessionsDir)); planningSessionsDir));
builder.Services.AddSingleton<IPlanningTerminalLauncher, WindowsTerminalPlanningLauncher>(); builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PlanningMcpContextAccessor>(); builder.Services.AddScoped<PlanningMcpContextAccessor>();
builder.Services.AddScoped<ClaudeDoDbContext>(sp => builder.Services.AddScoped<ClaudeDoDbContext>(sp =>

View File

@@ -19,6 +19,13 @@ sealed class FakeWorkerClient : IWorkerClient
public int FinalizePlanningCalls { get; private set; } public int FinalizePlanningCalls { get; private set; }
public int WakeQueueCalls { get; private set; } public int WakeQueueCalls { get; private set; }
public event Action<string>? TaskUpdatedEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string, string>? TaskMessageEvent;
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; } public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; } public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }