# Worker Log Footer Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Surface important Worker lifecycle events in the UI footer as a single rotating, color-coded line that auto-hides after 30s of silence.

**Architecture:** Add `WorkerLogLevel` enum in shared `ClaudeDo.Data` project. `HubBroadcaster` gets a `WorkerLog(message, level, timestampUtc)` SignalR event. Seven emit sites in `TaskRunner`, `TaskMergeService`, `TaskResetService` (callers of `WorktreeManager`, not WorktreeManager itself — they have the task title in scope). UI side: `WorkerClient` surfaces a `WorkerLogReceived` event; footer state lives on `IslandsShellViewModel` (existing root VM for `MainWindow`, also owns connection state); `System.Timers.Timer` clears the line after 30s; a `WorkerLogLevelToBrushConverter` maps level → brush in XAML.

**Tech Stack:** .NET 8, ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit.

**Spec:** `docs/superpowers/specs/2026-04-23-worker-log-footer-design.md`

**Deviation from spec:** Spec names `WorktreeManager.CreateAsync` / `DiscardAsync` as emit sites. In practice, `WorktreeManager` has only the task ID in scope; its callers (`TaskRunner`, `TaskResetService`) have the title. Emitting from callers avoids adding constructor dependencies to `WorktreeManager` and produces identical user-visible behavior.

**Build note:** Per project convention, `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csprojs.

---

### Task 1: Add `WorkerLogLevel` enum (shared contract)

**Files:**
- Create: `src/ClaudeDo.Data/Models/WorkerLogLevel.cs`

- [ ] **Step 1: Write the enum**

```csharp
namespace ClaudeDo.Data.Models;

public enum WorkerLogLevel
{
    Info,
    Success,
    Warn,
    Error,
}
```

- [ ] **Step 2: Build Data project**

Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: Build succeeded, 0 errors.

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Data/Models/WorkerLogLevel.cs
git commit -m "feat(data): add WorkerLogLevel enum"
```

---

### Task 2: Add `WorkerLog` broadcaster method + SignalR JSON enum-as-string

**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` (append method)
- Modify: `src/ClaudeDo.Worker/Program.cs` (line ~23 — the `AddSignalR()` call)

- [ ] **Step 1: Add enum-as-string serialization**

Replace:
```csharp
builder.Services.AddSignalR();
```
with:
```csharp
builder.Services.AddSignalR().AddJsonProtocol(options =>
{
    options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
```

- [ ] **Step 2: Add `WorkerLog` method to `HubBroadcaster`**

Add the following method inside `HubBroadcaster` class (after the existing `RunCreated` method):

```csharp
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
    _hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
```

Add to the using block at top of file (if not already present):
```csharp
using ClaudeDo.Data.Models;
```

- [ ] **Step 3: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): add WorkerLog SignalR event"
```

---

### Task 3: Emit `WorkerLog` from `TaskRunner`

**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`

Four emit sites in this file:
1. **Created worktree** — right after `_wtManager.CreateAsync` succeeds (around line 69).
2. **Started Claude** — just before invoking Claude process.
3. **Committed changes** — after auto-commit (before the `WorktreeUpdated` broadcast around line 318).
4. **Finished** — at both success (line 330) and failure paths, mirroring the existing `TaskFinished` call.

- [ ] **Step 1: Add using for `WorkerLogLevel`**

Ensure `TaskRunner.cs` has at the top:
```csharp
using ClaudeDo.Data.Models;
```

- [ ] **Step 2: Emit "Created worktree"**

After the line `wtCtx = await _wtManager.CreateAsync(task, list, ct);` (around line 69), add:
```csharp
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```
(Place inside the same `if` branch that called `CreateAsync`, after the assignment.)

- [ ] **Step 3: Emit "Started Claude"**

Locate the point just before `ClaudeProcess` is invoked (search for where `ClaudeProcess` or `RunProcessAsync` is called). Just before the invocation, add:
```csharp
await _broadcaster.WorkerLog($"Started Claude for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```

- [ ] **Step 4: Emit "Committed changes"**

Locate the auto-commit code path (around line 318, just before `await _broadcaster.WorktreeUpdated(task.Id);`). Add immediately before that call:
```csharp
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```

- [ ] **Step 5: Emit "Finished (done)"**

Find the success finish path (around line 330, where `TaskFinished` is broadcast with status `"done"`). Add immediately before that call:
```csharp
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
```

- [ ] **Step 6: Emit "Finished (failed)"**

Find the failure path (search for `TaskFinished` with status `"failed"`). Add immediately before:
```csharp
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
```

- [ ] **Step 7: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.

- [ ] **Step 8: Run existing Worker tests (no regressions)**

Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: All tests pass.

- [ ] **Step 9: Commit**

```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "feat(worker): emit WorkerLog events from TaskRunner"
```

---

### Task 4: Emit `WorkerLog` from `TaskMergeService` and `TaskResetService`

**Files:**
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
- Modify: `src/ClaudeDo.Worker/Services/TaskResetService.cs`

Both services already have `HubBroadcaster` injected (`_broadcaster`). Both already load the task entity (needed for title).

- [ ] **Step 1: Add using in both files**

Add to the top of each file (if not already present):
```csharp
using ClaudeDo.Data.Models;
```

- [ ] **Step 2: Emit "Merged" in `TaskMergeService.MergeAsync`**

Locate the existing log line around line 137:
```csharp
_logger.LogInformation(
    "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
    ...);
```

Immediately after it (before `return new MergeResult(...)` on line 140), add:
```csharp
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
```
Use whatever variable names `task` and `targetBranch` are in scope — adjust to match the actual local names at that site.

- [ ] **Step 3: Emit "Discarded" in `TaskResetService.ResetAsync`**

Locate the call `await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);` (line 53). Immediately after it, add:
```csharp
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
```

- [ ] **Step 4: Emit "Reset" in `TaskResetService.ResetAsync`**

Locate the existing line `_logger.LogInformation("Reset task {TaskId} to Manual ...` (line 66). Immediately after it, add:
```csharp
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
```

- [ ] **Step 5: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.

- [ ] **Step 6: Run existing Worker tests**

Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: All tests pass.

- [ ] **Step 7: Commit**

```bash
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs src/ClaudeDo.Worker/Services/TaskResetService.cs
git commit -m "feat(worker): emit WorkerLog for merge, discard, reset"
```

---

### Task 5: Add `WorkerLogEntry` record + `WorkerLogReceived` event on `WorkerClient`

**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`

- [ ] **Step 1: Add using for `WorkerLogLevel`**

Add at the top of `WorkerClient.cs`:
```csharp
using ClaudeDo.Data.Models;
```

- [ ] **Step 2: Declare the `WorkerLogEntry` record**

Add at the top of the file (above or below the `WorkerClient` class, same namespace):
```csharp
public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc);
```

- [ ] **Step 3: Add the event field**

Alongside the other `public event Action<...>?` declarations (around lines 42-48), add:
```csharp
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
```

- [ ] **Step 4: Register the SignalR handler**

Alongside the other `_hub.On<...>` registrations (around lines 80-117), add:
```csharp
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
{
    WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
});
```

- [ ] **Step 5: Build**

Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded, 0 errors.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): subscribe to WorkerLog SignalR event"
```

---

### Task 6: Create `ClaudeDo.Ui.Tests` project and add `WorkerLogLevelToBrushConverter`

**Files:**
- Create: `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`
- Modify: `src/ClaudeDo.Ui/App.axaml` (register converter as resource)
- Create: `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
- Create: `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`

- [ ] **Step 1: Write the converter**

Create `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`:

```csharp
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Data.Models;

namespace ClaudeDo.Ui.Converters;

public sealed class WorkerLogLevelToBrushConverter : IValueConverter
{
    private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50"));
    private static readonly IBrush WarnBrush    = new SolidColorBrush(Color.Parse("#FFA726"));
    private static readonly IBrush ErrorBrush   = new SolidColorBrush(Color.Parse("#EF5350"));
    private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888"));

    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is not WorkerLogLevel level)
            return AvaloniaProperty.UnsetValue;

        return level switch
        {
            WorkerLogLevel.Success => SuccessBrush,
            WorkerLogLevel.Warn    => WarnBrush,
            WorkerLogLevel.Error   => ErrorBrush,
            WorkerLogLevel.Info    => ResolveInfoBrush(),
            _                      => AvaloniaProperty.UnsetValue,
        };
    }

    public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
        throw new NotSupportedException();

    private static IBrush ResolveInfoBrush()
    {
        if (Application.Current is { } app &&
            app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) &&
            res is IBrush brush)
        {
            return brush;
        }
        return InfoFallback;
    }
}
```

- [ ] **Step 2: Register converter in `App.axaml`**

Open `src/ClaudeDo.Ui/App.axaml`. Inside the `<Application.Resources>` section (add one if missing), add alongside any existing converter entries:

```xml
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
```

Ensure the `xmlns:converters="using:ClaudeDo.Ui.Converters"` namespace is declared at the root `<Application>` element. If other converters (e.g. `StatusColorConverter`) are already resources in `App.axaml` follow the same pattern; if they're declared per-view, declare this converter at the top of `MainWindow.axaml` in Task 8 instead.

- [ ] **Step 3: Create UI test project**

Create `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`:

```xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Avalonia" Version="12.0.0" />
    <PackageReference Include="Avalonia.Headless" Version="12.0.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
    <PackageReference Include="xunit" Version="2.9.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
  </ItemGroup>
</Project>
```

If the existing `tests/ClaudeDo.Worker.Tests/*.csproj` uses different `Microsoft.NET.Test.Sdk` / xUnit versions, match those versions exactly to avoid analyzer mismatches.

- [ ] **Step 4: Write the failing test**

Create `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`:

```csharp
using System.Globalization;
using Avalonia;
using Avalonia.Media;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Converters;
using Xunit;

namespace ClaudeDo.Ui.Tests;

public class WorkerLogLevelToBrushConverterTests
{
    [Theory]
    [InlineData(WorkerLogLevel.Success, "#FF4CAF50")]
    [InlineData(WorkerLogLevel.Warn,    "#FFFFA726")]
    [InlineData(WorkerLogLevel.Error,   "#FFEF5350")]
    public void Convert_maps_level_to_expected_brush_color(WorkerLogLevel level, string expectedArgb)
    {
        var converter = new WorkerLogLevelToBrushConverter();

        var result = converter.Convert(level, typeof(IBrush), null, CultureInfo.InvariantCulture);

        var solid = Assert.IsType<SolidColorBrush>(result);
        Assert.Equal(expectedArgb.ToLowerInvariant(), $"#{solid.Color.ToUInt32():X8}".ToLowerInvariant());
    }

    [Fact]
    public void Convert_info_returns_a_brush_fallback_when_no_app()
    {
        var converter = new WorkerLogLevelToBrushConverter();

        var result = converter.Convert(WorkerLogLevel.Info, typeof(IBrush), null, CultureInfo.InvariantCulture);

        Assert.IsAssignableFrom<IBrush>(result);
    }

    [Fact]
    public void Convert_unknown_value_returns_unset()
    {
        var converter = new WorkerLogLevelToBrushConverter();

        var result = converter.Convert("not a level", typeof(IBrush), null, CultureInfo.InvariantCulture);

        Assert.Equal(AvaloniaProperty.UnsetValue, result);
    }
}
```

- [ ] **Step 5: Run the tests**

Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: All 5 tests pass.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs src/ClaudeDo.Ui/App.axaml tests/ClaudeDo.Ui.Tests/
git commit -m "feat(ui): add WorkerLogLevelToBrushConverter with tests"
```

---

### Task 7: Add footer state + 30s auto-clear timer to `IslandsShellViewModel`

**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Create: `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`

Timer uses `System.Timers.Timer` (not `DispatcherTimer`) so unit tests don't need an Avalonia dispatcher. The elapsed callback marshals to the UI thread via `Dispatcher.UIThread.Post` when the dispatcher is available; in tests the VM logic under test sets properties directly so no marshalling is needed.

- [ ] **Step 1: Write the failing tests**

Create `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`:

```csharp
using System;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Xunit;

namespace ClaudeDo.Ui.Tests;

public class IslandsShellViewModelWorkerLogTests
{
    private static IslandsShellViewModel NewVm() =>
        // The real constructor requires island VMs + WorkerClient. These tests
        // only exercise the WorkerLog handling, so we use a test-only constructor
        // that bypasses the sub-VMs. Add `internal IslandsShellViewModel()` for tests.
        IslandsShellViewModel.CreateForTests();

    [Fact]
    public void Receiving_event_sets_text_level_and_visible()
    {
        var vm = NewVm();
        var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc);

        vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at));

        Assert.True(vm.IsWorkerLogVisible);
        Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel);
        Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText);
    }

    [Fact]
    public void Second_event_replaces_first()
    {
        var vm = NewVm();
        vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info,    DateTime.UtcNow));
        vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow));

        Assert.Contains("second", vm.WorkerLogText);
        Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
    }

    [Fact]
    public void ClearWorkerLog_hides_line()
    {
        var vm = NewVm();
        vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow));

        vm.ClearWorkerLog();

        Assert.False(vm.IsWorkerLogVisible);
        Assert.Null(vm.WorkerLogText);
    }

    [Fact]
    public void Text_is_formatted_as_HHmm_dot_message_local_time()
    {
        var vm = NewVm();
        var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc);
        var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm");

        vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc));

        Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText);
    }
}
```

- [ ] **Step 2: Run tests to confirm they fail to compile**

Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: Build errors — `CreateForTests`, `OnWorkerLogReceived`, `ClearWorkerLog`, `IsWorkerLogVisible`, `WorkerLogText`, `WorkerLogLevel` do not yet exist.

- [ ] **Step 3: Implement on `IslandsShellViewModel`**

Open `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`. Add `using`s if missing:

```csharp
using System.Timers;
using Avalonia.Threading;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
```

Inside the class, add:

```csharp
[ObservableProperty] private string? workerLogText;
[ObservableProperty] private WorkerLogLevel workerLogLevel;
[ObservableProperty] private bool isWorkerLogVisible;

private readonly Timer _workerLogTimer = new(TimeSpan.FromSeconds(30).TotalMilliseconds)
{
    AutoReset = false,
};

internal static IslandsShellViewModel CreateForTests() =>
    (IslandsShellViewModel)System.Runtime.Serialization.FormatterServices
        .GetUninitializedObject(typeof(IslandsShellViewModel));
```

(If `FormatterServices` is unavailable under `net8.0`, instead add a parameterless `internal IslandsShellViewModel() {}` constructor guarded for tests only.)

In the existing real constructor, wire up subscription (after the line `Worker.PropertyChanged += ...` block, around line 63-70):

```csharp
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
_workerLogTimer.Elapsed += (_, _) =>
{
    if (Dispatcher.UIThread.CheckAccess()) ClearWorkerLog();
    else Dispatcher.UIThread.Post(ClearWorkerLog);
};
```

Add the methods the tests call:

```csharp
public void OnWorkerLogReceived(WorkerLogEntry entry)
{
    var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
    WorkerLogText = $"{hhmm} · {entry.Message}";
    WorkerLogLevel = entry.Level;
    IsWorkerLogVisible = true;

    _workerLogTimer.Stop();
    _workerLogTimer.Start();
}

public void ClearWorkerLog()
{
    IsWorkerLogVisible = false;
    WorkerLogText = null;
}
```

- [ ] **Step 4: Run tests**

Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: All tests pass (5 converter + 4 VM = 9 tests).

- [ ] **Step 5: Build the UI project as a sanity check**

Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs
git commit -m "feat(ui): add worker log state and 30s timer to shell VM"
```

---

### Task 8: Update `MainWindow.axaml` footer — dock log line right

**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 104-135 — the footer `Border`)

- [ ] **Step 1: Add the converter resource to the window (if not already in App.axaml)**

If Task 6 declared the converter in `App.axaml`, skip this step. Otherwise, add a `<Window.Resources>` block near the top of `MainWindow.axaml`:
```xml
<Window.Resources>
    <converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
</Window.Resources>
```
Ensure `xmlns:converters="using:ClaudeDo.Ui.Converters"` is declared on the root `<Window>`.

- [ ] **Step 2: Replace the footer body**

Replace the existing footer `<Border Grid.Row="2" ...>` inner contents (the `<StackPanel>` at lines 109-134) with:

```xml
<DockPanel LastChildFill="True" Margin="14,0">
    <!-- Left: connection pill -->
    <StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="7"
                VerticalAlignment="Center">
        <Ellipse Width="7" Height="7" Fill="#4CAF50"
                 IsVisible="{Binding Worker.IsConnected}"/>
        <Ellipse Width="7" Height="7" Fill="#FFA726"
                 IsVisible="{Binding Worker.IsReconnecting}"/>
        <Ellipse Width="7" Height="7" Fill="#EF5350"
                 IsVisible="{Binding IsOffline}"/>
        <TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
                   FontFamily="{DynamicResource MonoFont}"
                   FontSize="10"
                   LetterSpacing="1.4"
                   Foreground="{DynamicResource TextDimBrush}"
                   VerticalAlignment="Center"/>
    </StackPanel>

    <!-- Right: worker log line -->
    <TextBlock DockPanel.Dock="Right"
               Text="{Binding WorkerLogText}"
               IsVisible="{Binding IsWorkerLogVisible}"
               Foreground="{Binding WorkerLogLevel, Converter={StaticResource WorkerLogLevelToBrush}}"
               FontFamily="{DynamicResource MonoFont}"
               FontSize="10"
               LetterSpacing="1.4"
               TextTrimming="CharacterEllipsis"
               VerticalAlignment="Center"/>

    <!-- Spacer (fills remaining space between pill and log) -->
    <Panel/>
</DockPanel>
```

- [ ] **Step 3: Build the UI**

Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded, 0 errors. No XAML compilation errors.

- [ ] **Step 4: Build the full app (entry point)**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.

- [ ] **Step 5: Manual smoke test**

Start the Worker and the App (two separate processes per CLAUDE.md).

Exercise each event and confirm the footer line appears with the expected color and copy:

1. Start a task → expect `HH:MM · Created worktree for "<title>"` (dim/info).
2. Observe while Claude runs → expect `HH:MM · Started Claude for "<title>"` (dim/info).
3. Task commits → expect `HH:MM · Committed changes in "<title>"` (dim/info).
4. Task finishes successfully → expect `HH:MM · Finished "<title>" (done)` (green).
5. Trigger a failing task → expect `HH:MM · Finished "<title>" (failed)` (red).
6. Reset a failed task → expect `HH:MM · Discarded worktree for "<title>"` (amber) followed by `HH:MM · Reset "<title>"` (amber).
7. Merge a completed task → expect `HH:MM · Merged "<title>" into <branch>` (green).
8. Wait 30s with no new events → footer log line disappears (connection pill remains).
9. Trigger a burst of 3 events in quick succession → only the most recent is shown; timer resets on each.
10. Long task title (≥60 chars) → line is ellipsized, connection pill on the left remains fully visible.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "feat(ui): show worker log line in footer"
```

---

## Self-Review

- **Spec coverage:**
    - Enum `WorkerLogLevel` in `ClaudeDo.Data` — Task 1 ✓
    - SignalR enum-as-string — Task 2 ✓
    - `HubBroadcaster.WorkerLog` — Task 2 ✓
    - 7 emit sites with correct level mapping — Tasks 3, 4 ✓
    - `WorkerClient.WorkerLogReceived` event + `WorkerLogEntry` record — Task 5 ✓
    - `WorkerLogLevelToBrushConverter` with unit tests — Task 6 ✓
    - Footer VM state + 30s timer + tests — Task 7 ✓
    - Footer XAML (DockPanel, connection left, log right, level-based color, ellipsis) — Task 8 ✓
    - Out-of-scope items (history drawer, filtering, persistence) — correctly omitted ✓

- **Placeholder scan:** No "TBD" / "handle edge cases" / "similar to Task N". All code is inline.

- **Type consistency:** `WorkerLogEntry(Message, Level, TimestampUtc)` — same signature used in Task 5 (declaration), Task 7 (consumer tests + VM). `WorkerLog(message, level, timestampUtc)` — same signature in Task 2 (broadcaster) and Tasks 3-4 (callers). `OnWorkerLogReceived` / `ClearWorkerLog` / `IsWorkerLogVisible` / `WorkerLogText` / `WorkerLogLevel` — consistent between Task 7 test and Task 7 implementation.
