Compare commits
203 Commits
v1.2.0
...
450e685580
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
450e685580 | ||
|
|
0e116bec7b | ||
|
|
47b49743c0 | ||
|
|
506caa2c53 | ||
|
|
388a8c1fae | ||
|
|
42b208ff28 | ||
|
|
309f84b388 | ||
|
|
00608401aa | ||
|
|
229d4bbb2b | ||
| 845359b885 | |||
|
|
d4a46420c9 | ||
|
|
f704244b84 | ||
|
|
782110604b | ||
|
|
19bf032a2e | ||
|
|
b7464c9a11 | ||
|
|
524aaf85af | ||
|
|
a9e7479326 | ||
|
|
2e80cc606e | ||
|
|
d099138487 | ||
|
|
2278d97b7e | ||
|
|
74255ddc82 | ||
|
|
b466246c1b | ||
|
|
b3eb39a28b | ||
|
|
253e6f05e0 | ||
|
|
042a1b47c2 | ||
|
|
7a20534e7c | ||
|
|
ee2cbc92ef | ||
|
|
373f04a034 | ||
|
|
43d517dcfc | ||
|
|
8891d48af2 | ||
|
|
0b72c0fb53 | ||
|
|
a41e5b5b2d | ||
|
|
00c62178e1 | ||
|
|
bbe7d73de2 | ||
|
|
0934b294c2 | ||
|
|
b28d8f2f4a | ||
|
|
ec4ec44603 | ||
|
|
ee09706811 | ||
|
|
c06d1d6afb | ||
|
|
f906e7086c | ||
|
|
caf900b02d | ||
|
|
e80e3fccc0 | ||
|
|
e8056553fd | ||
|
|
ea4d2d7c0c | ||
|
|
98c188a5da | ||
|
|
0c3dcb0052 | ||
|
|
e017d66023 | ||
|
|
ba0b38b4f1 | ||
|
|
5b4cdd366e | ||
|
|
7c0f8d8408 | ||
|
|
0a7fcae137 | ||
|
|
5346737e2b | ||
|
|
80f6669585 | ||
|
|
27054e6715 | ||
|
|
ea7694566d | ||
|
|
46e01aefed | ||
|
|
41e0bea162 | ||
|
|
86012e02b9 | ||
|
|
da19eb807b | ||
|
|
0d37473575 | ||
|
|
6a4bf676ff | ||
|
|
a135485339 | ||
|
|
3c420acd54 | ||
|
|
5ced1b97a6 | ||
|
|
1344beba56 | ||
|
|
c8c8bb4a47 | ||
|
|
6f725d12f5 | ||
|
|
9952ff98f2 | ||
|
|
4a6d96b90e | ||
|
|
2690332d13 | ||
|
|
31218fc205 | ||
|
|
cc01871407 | ||
|
|
e70ae7f6ce | ||
|
|
1830273a9d | ||
|
|
1a10e6fa09 | ||
|
|
df57c2bc05 | ||
|
|
990be09bd7 | ||
|
|
e275f67a5e | ||
|
|
ff3de1d100 | ||
|
|
a4e313dbad | ||
|
|
7de5510735 | ||
|
|
5e54275842 | ||
|
|
6ac88235a7 | ||
|
|
c599fdcb8c | ||
|
|
b0b15e474e | ||
|
|
839f862b7d | ||
|
|
2901a769d8 | ||
|
|
e74e7eecf4 | ||
|
|
bba577888b | ||
|
|
5784dbee94 | ||
|
|
5348220e60 | ||
|
|
cd0b95ef9a | ||
|
|
fc1cfe59ec | ||
|
|
7c312161bb | ||
|
|
480eb0817a | ||
|
|
1b94fa5c44 | ||
|
|
02464b7f89 | ||
|
|
68f461d0e1 | ||
|
|
cfb410dd4d | ||
|
|
b378fbf550 | ||
|
|
cb43bcdd10 | ||
|
|
31420574db | ||
|
|
07dee31847 | ||
|
|
4debd5ce09 | ||
|
|
1495c63e3d | ||
|
|
953d93179d | ||
|
|
1bc7fcc609 | ||
|
|
c911717a3b | ||
|
|
949911f6c8 | ||
|
|
f3a58a6515 | ||
|
|
ee4cd706ef | ||
|
|
e11b01951e | ||
|
|
3d0cc4ffed | ||
|
|
4585b20f80 | ||
|
|
c53b5878cf | ||
|
|
c13ae437f7 | ||
|
|
5780879629 | ||
|
|
2bcd5ef9bd | ||
|
|
63eb860e40 | ||
|
|
e80ac7de49 | ||
|
|
3331c24898 | ||
|
|
1c20d8f846 | ||
|
|
77a1460e3a | ||
|
|
21a1870fd7 | ||
|
|
3ebbdb3f6e | ||
|
|
535d0c5558 | ||
|
|
2d807aa606 | ||
|
|
93ee7b72d5 | ||
|
|
32ef1b389a | ||
|
|
0885518a68 | ||
|
|
944d3bd3e8 | ||
|
|
fb89e02b02 | ||
|
|
58c8210afa | ||
|
|
2ce6b7bd3a | ||
|
|
f90d3d8375 | ||
|
|
b03e858a8f | ||
|
|
2278b516ea | ||
|
|
219a231f32 | ||
|
|
74eb36d3c0 | ||
|
|
202236a45b | ||
|
|
88be19a231 | ||
|
|
44203f3c67 | ||
|
|
133774cb86 | ||
|
|
a3bb557d76 | ||
|
|
23f8fddc4d | ||
|
|
a180e8446c | ||
|
|
0406d35b61 | ||
|
|
e6b37624a1 | ||
|
|
fca5d57fef | ||
|
|
cfb9ca1ca4 | ||
|
|
62a1121571 | ||
|
|
4283c67d81 | ||
|
|
883c98dc0a | ||
|
|
374e811e78 | ||
|
|
ec679e45ed | ||
|
|
3a67fe81b4 | ||
|
|
dc6e3fe442 | ||
|
|
b525498770 | ||
|
|
668087cda4 | ||
|
|
b4741137d0 | ||
|
|
e19a9d373e | ||
|
|
42fb7cee0d | ||
|
|
5acc896d5c | ||
|
|
9b1178ca2f | ||
|
|
01af8cb7d7 | ||
|
|
c3f077e3b6 | ||
|
|
b64ff3d908 | ||
|
|
82f2d526a0 | ||
|
|
0ef7113958 | ||
|
|
940b72f8dd | ||
|
|
287e098c3a | ||
|
|
4531b95c42 | ||
|
|
0bf2d78fba | ||
|
|
480d05975d | ||
|
|
27c6a4b859 | ||
|
|
2d1a4881aa | ||
|
|
62aac7eedb | ||
|
|
279f2c7598 | ||
|
|
95146518b2 | ||
|
|
eee98b7828 | ||
|
|
5a17a727b9 | ||
|
|
6dade011b0 | ||
|
|
47e8e1ff94 | ||
|
|
abd7733c90 | ||
|
|
4d68543cf2 | ||
|
|
f94bb35db7 | ||
|
|
4f41b084fa | ||
|
|
fcf53ab4f5 | ||
|
|
0034accb4f | ||
|
|
f167120c90 | ||
|
|
dc1b648b4c | ||
|
|
06cc141176 | ||
|
|
05404f46f2 | ||
|
|
8909119d1b | ||
|
|
55917c921a | ||
|
|
1893576b6a | ||
|
|
9a05907170 | ||
|
|
92a6e0642e | ||
|
|
579b527dcd | ||
|
|
bd8a4d0565 | ||
|
|
928dde1358 | ||
|
|
a1190a35bd | ||
|
|
eff1045e63 |
@@ -2,7 +2,11 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)"
|
"Bash(git commit:*)",
|
||||||
|
"mcp__plugin_context-mode_context-mode__batch_execute",
|
||||||
|
"mcp__plugin_context-mode_context-mode__execute",
|
||||||
|
"mcp__plugin_context7_context7__query-docs",
|
||||||
|
"mcp__plugin_context-mode_context-mode__search"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
cd "$WORK/src"
|
cd "$WORK/src"
|
||||||
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
|
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
|
||||||
-c Release -r win-x64 --self-contained true \
|
-c Release -r win-x64 --self-contained true \
|
||||||
/p:Version=$VERSION -o out/app
|
/p:MinVerVersionOverride=$VERSION -o out/app
|
||||||
|
|
||||||
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
|
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
|
||||||
env:
|
env:
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
cd "$WORK/src"
|
cd "$WORK/src"
|
||||||
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
|
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
|
||||||
-c Release -r win-x64 --self-contained true \
|
-c Release -r win-x64 --self-contained true \
|
||||||
/p:Version=$VERSION -o out/worker
|
/p:MinVerVersionOverride=$VERSION -o out/worker
|
||||||
|
|
||||||
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
||||||
env:
|
env:
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
# Target machines need .NET 8 Desktop Runtime (x64).
|
# Target machines need .NET 8 Desktop Runtime (x64).
|
||||||
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
||||||
-c Release -r win-x64 --self-contained false \
|
-c Release -r win-x64 --self-contained false \
|
||||||
/p:Version=$VERSION /p:PublishSingleFile=true \
|
/p:MinVerVersionOverride=$VERSION /p:PublishSingleFile=true \
|
||||||
-o out/installer
|
-o out/installer
|
||||||
|
|
||||||
- name: Package assets
|
- name: Package assets
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# Local dev worktrees (created by using-git-worktrees skill)
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# .NET build output
|
# .NET build output
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
||||||
|
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||||
|
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<MinVerTagPrefix>v</MinVerTagPrefix>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
25
README.md
25
README.md
@@ -16,29 +16,29 @@ Two-process system communicating over SignalR:
|
|||||||
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
|
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐ SignalR ┌──────────────┐
|
┌────────────────┐ SignalR ┌────────────────┐
|
||||||
│ ClaudeDo.App│◄──────────►│ClaudeDo.Worker│
|
│ ClaudeDo.App │◄───────────►│ ClaudeDo.Worker │
|
||||||
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET) │
|
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core) │
|
||||||
│ │ :47821 │ │
|
│ │ :47821 │ │
|
||||||
│ ┌──────────┐│ │ ┌──────────┐ │
|
│ ┌────────────┐│ │ ┌────────────┐ │
|
||||||
│ │ Ui ││ │ │ TaskQueue │ │
|
│ │ Ui ││ │ │ TaskQueue │ │
|
||||||
│ │(ViewModels)│ │ │ Claude CLI│ │
|
│ │(ViewModels)││ │ │ Claude CLI │ │
|
||||||
│ └──────────┘│ │ └──────────┘ │
|
│ └────────────┘│ │ └────────────┘ │
|
||||||
└──────┬───────┘ └──────┬───────┘
|
└───────┬────────┘ └───────┬────────┘
|
||||||
│ │
|
│ │
|
||||||
└───────────┬───────────────┘
|
└──────────────┬───────────────┘
|
||||||
│
|
│
|
||||||
┌──────┴──────┐
|
┌───────┴───────┐
|
||||||
│ ClaudeDo.Data │
|
│ ClaudeDo.Data │
|
||||||
│ (SQLite) │
|
│ (SQLite) │
|
||||||
└─────────────┘
|
└───────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- .NET 8.0
|
- .NET 8.0
|
||||||
- Avalonia 12.0.0 (Fluent theme)
|
- Avalonia 12.0.0 (Fluent theme)
|
||||||
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
|
- SQLite (WAL mode) via Entity Framework Core (EF Core + Migrations)
|
||||||
- SignalR for real-time IPC between UI and Worker
|
- SignalR for real-time IPC between UI and Worker
|
||||||
- CommunityToolkit.Mvvm for source-generated MVVM
|
- CommunityToolkit.Mvvm for source-generated MVVM
|
||||||
- Git worktrees for task isolation
|
- Git worktrees for task isolation
|
||||||
@@ -53,7 +53,8 @@ Two-process system communicating over SignalR:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
# Build
|
||||||
dotnet build ClaudeDo.slnx
|
dotnet build src/ClaudeDo.App
|
||||||
|
dotnet build src/ClaudeDo.Worker
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
dotnet test tests/ClaudeDo.Worker.Tests
|
dotnet test tests/ClaudeDo.Worker.Tests
|
||||||
|
|||||||
184
docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html
Normal file
184
docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html
Normal file
File diff suppressed because one or more lines are too long
36
docs/UI Rewrite/design_handoff_claudedo/ClaudeDo.html
Normal file
36
docs/UI Rewrite/design_handoff_claudedo/ClaudeDo.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>ClaudeDo — Rider Island</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="styles.css?v=2" />
|
||||||
|
<template id="__bundler_thumbnail">
|
||||||
|
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="200" fill="#0d1311"/>
|
||||||
|
<rect x="30" y="60" width="36" height="80" rx="6" fill="#161d1a" stroke="#2a3330"/>
|
||||||
|
<rect x="76" y="60" width="60" height="80" rx="6" fill="#161d1a" stroke="#2a3330"/>
|
||||||
|
<rect x="146" y="60" width="24" height="80" rx="6" fill="#161d1a" stroke="#2a3330"/>
|
||||||
|
<circle cx="90" cy="85" r="4" fill="#4a6b4a"/>
|
||||||
|
<circle cx="90" cy="105" r="4" stroke="#3a4542" fill="none"/>
|
||||||
|
<circle cx="90" cy="125" r="4" stroke="#3a4542" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="data.jsx"></script>
|
||||||
|
<script type="text/babel" src="icons.jsx"></script>
|
||||||
|
<script type="text/babel" src="islands.jsx"></script>
|
||||||
|
<script type="text/babel" src="modals.jsx"></script>
|
||||||
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
240
docs/UI Rewrite/design_handoff_claudedo/IslandStyles.axaml
Normal file
240
docs/UI Rewrite/design_handoff_claudedo/IslandStyles.axaml
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<!--
|
||||||
|
ClaudeDo component styles for Avalonia.
|
||||||
|
Depends on Tokens.axaml being merged first.
|
||||||
|
|
||||||
|
How to use each style:
|
||||||
|
<Border Classes="island"> — floating island container
|
||||||
|
<Border Classes="chip running"> — status chip
|
||||||
|
<Button Classes="icon-btn"> — 24×24 icon button
|
||||||
|
<Button Classes="btn primary"> — rounded-rect button
|
||||||
|
<TextBlock Classes="eyebrow"> — uppercase mono label
|
||||||
|
<Border Classes="agent-strip running"> — agent status strip
|
||||||
|
<Border Classes="terminal"> — terminal/log window
|
||||||
|
-->
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ISLAND -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.island">
|
||||||
|
<Setter Property="Background" Value="{StaticResource IslandBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="#0DFFFFFF" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource IslandCornerRadius}" />
|
||||||
|
<Setter Property="BoxShadow" Value="{StaticResource IslandShadow}" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Island header separator (apply on the header Border inside an island) -->
|
||||||
|
<Style Selector="Border.island-header">
|
||||||
|
<Setter Property="Padding" Value="{StaticResource IslandHeaderPadding}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CHIPS / BADGES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.chip">
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ChipCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="8,3" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip > TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Status variants — tint background 12% alpha of the status hue -->
|
||||||
|
<Style Selector="Border.chip.running">
|
||||||
|
<Setter Property="Background" Value="#1F7C9166" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4C7C9166" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.running > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource StatusRunningBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.chip.review">
|
||||||
|
<Setter Property="Background" Value="#1FD4A574" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CD4A574" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.review > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource StatusReviewBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.chip.error">
|
||||||
|
<Setter Property="Background" Value="#1FC87060" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CC87060" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.error > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource StatusErrorBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- BUTTONS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Button.btn">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ButtonCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="10,6" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||||
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.btn:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.btn.primary">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Icon button: 24×24 square with hover surface -->
|
||||||
|
<Style Selector="Button.icon-btn">
|
||||||
|
<Setter Property="Width" Value="24" />
|
||||||
|
<Setter Property="Height" Value="24" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- INPUTS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="TextBox.search">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource InputCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.search:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="BoxShadow" Value="0 0 0 3 #387C9166" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TASK ROW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.task-row">
|
||||||
|
<Setter Property="Padding" Value="12,10" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row.selected">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="0,0,0,0" />
|
||||||
|
<!-- Left-edge accent bar: use a nested Border child with Width=2 instead of inset shadow -->
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
||||||
|
<Style Selector="Ellipse.task-check">
|
||||||
|
<Setter Property="Width" Value="18" />
|
||||||
|
<Setter Property="Height" Value="18" />
|
||||||
|
<Setter Property="StrokeThickness" Value="1.5" />
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="Fill" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.task-check.done">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- AGENT STRIP -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.agent-strip">
|
||||||
|
<Setter Property="Padding" Value="12,10" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.agent-strip.running">
|
||||||
|
<Setter Property="Background" Value="#147C9166" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4C7C9166" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.agent-strip.review">
|
||||||
|
<Setter Property="Background" Value="#14D4A574" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CD4A574" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.agent-strip.error">
|
||||||
|
<Setter Property="Background" Value="#14C87060" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CC87060" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TERMINAL / LOG -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.terminal">
|
||||||
|
<Setter Property="Background" Value="#FF080C0B" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="12" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock.log-sys">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock.log-tool">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock.log-claude">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock.log-stderr">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock.log-done">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- LIST NAV ITEM -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.list-item">
|
||||||
|
<Setter Property="Padding" Value="10,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.list-item:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.list-item.active">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</Styles>
|
||||||
253
docs/UI Rewrite/design_handoff_claudedo/README.md
Normal file
253
docs/UI Rewrite/design_handoff_claudedo/README.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# ClaudeDo — Avalonia Handoff
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ClaudeDo is an agent dispatcher for Claude Code: a Windows desktop app that presents background coding agents as tasks. Each task has a title, a list, a git worktree/branch, an agent status (idle / queued / running / review / error), a live session log, and a diff. The UI is organised as **three floating islands** (Lists / Tasks / Details) over a dark "sea" background, Windows-11 style.
|
||||||
|
|
||||||
|
The bundled HTML file is a **design reference**, not production code. Your job is to recreate it as a native Avalonia app — match the look, feel, and interaction model, but use idiomatic AXAML, Avalonia controls, and whatever MVVM / ReactiveUI / CommunityToolkit patterns your codebase already uses.
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High-fidelity.** All colors, typography, spacing, corner radii, shadows, and interaction states are final. Recreate pixel-perfectly using Avalonia primitives. The one exception: motion — CSS animations translate approximately to Avalonia `Transitions` / `Animation`; the durations and easings in `Tokens.axaml` are the intent.
|
||||||
|
|
||||||
|
## What's in this package
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Tokens.axaml` | `ResourceDictionary` — colors, brushes, spacing, corner radii, typography, shadows, motion durations. **Merge this first in `App.axaml`.** |
|
||||||
|
| `IslandStyles.axaml` | `Styles` — classed styles for Island, Chip, Button, TextBox, TaskRow, AgentStrip, Terminal, ListItem. Depends on `Tokens.axaml`. |
|
||||||
|
| `ClaudeDo.html` | The live design reference — open it in a browser to see behavior, hover states, animations, modals. |
|
||||||
|
| `ClaudeDo-standalone.html` | Fully offline single-file version (no network). Ship this with the handoff. |
|
||||||
|
| `app.jsx`, `islands.jsx`, `modals.jsx`, `icons.jsx`, `data.jsx`, `styles.css` | Source of the reference. Read `styles.css` for any measurement you need to verify; read the JSX for component structure and state transitions. |
|
||||||
|
| `ComponentSpec.md` | This file section below — maps every visual element to the AXAML control you should use. |
|
||||||
|
|
||||||
|
## How to wire the tokens
|
||||||
|
|
||||||
|
In `App.axaml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceInclude Source="avares://YourApp/Design/Tokens.axaml" />
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://YourApp/Design/IslandStyles.axaml" />
|
||||||
|
</Application.Styles>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pack **Inter Tight** (sans) and **JetBrains Mono** (mono) as embedded resources and reference them via `avares://YourApp/Assets/Fonts/#Inter Tight` in `Tokens.axaml` if the system-font fallback isn't good enough.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Window chrome
|
||||||
|
|
||||||
|
The reference shows a Windows-11-style app in a chromeless window with a custom title bar and taskbar. For a native Avalonia app, use `SystemDecorations="None"` + `ExtendClientAreaToDecorationsHint="True"` and draw your own title bar, OR use the platform chrome — the islands-over-sea metaphor works either way. The taskbar strip at the bottom of the reference is decorative; drop it.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Root window:
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TitleBar │
|
||||||
|
├─────────────┬──────────────────────────┬────────────────┤
|
||||||
|
│ Lists │ Tasks │ Details │
|
||||||
|
│ (260px) │ (1fr, min 340px) │ (320px) │
|
||||||
|
│ │ │ hides <1100px │
|
||||||
|
└─────────────┴──────────────────────────┴────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a `Grid` with 3 columns: `260,*,320`. Collapse the Details column when `ActualWidth < 1100` via a bound `ColumnDefinition.Width`. Between columns and around the grid, add 14px gap — put each island in a `Border Classes="island"` with `Margin="7"` so you get the island-to-island gap naturally.
|
||||||
|
|
||||||
|
Background of the grid cell: apply `DesktopBackgroundBrush` from tokens, plus a subtle radial-gradient overlay via a `Border` with an opacity mask if desired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Island (base container)
|
||||||
|
|
||||||
|
- `Border Classes="island"`
|
||||||
|
- Contents: header section + scrollable body
|
||||||
|
- Header: inner `Border Classes="island-header"` with an eyebrow label (mono, uppercase, tracking 1.4) and a title
|
||||||
|
- Body: `ScrollViewer` → `StackPanel` or `ItemsControl`
|
||||||
|
- All three columns are islands.
|
||||||
|
|
||||||
|
### Lists island (left)
|
||||||
|
|
||||||
|
- Search box: `TextBox Classes="search"` with left-aligned search icon (PathIcon)
|
||||||
|
- Nav items: `ItemsControl` bound to a `Lists` collection
|
||||||
|
- Each item is `Border Classes="list-item"` (toggle `active` class when selected) containing
|
||||||
|
- `PathIcon` (16px)
|
||||||
|
- `TextBlock` (list name, 13px)
|
||||||
|
- `TextBlock` (count, mono 10px, right-aligned, TextFaintBrush)
|
||||||
|
- Lists shown: My Day, Important, Planned, Running, Review, Tasks (by project name)
|
||||||
|
|
||||||
|
### Tasks island (middle)
|
||||||
|
|
||||||
|
Header row:
|
||||||
|
- Eyebrow: weekday date ("MONDAY · APR 28")
|
||||||
|
- Title: "My Day" (or current list name) — 24px semibold
|
||||||
|
- Subtitle: "{N} open · {N} running · {N} in review" — mono 11px TextMute
|
||||||
|
- Right side: icon buttons (sort, filter, show-completed toggle)
|
||||||
|
|
||||||
|
Add-task row:
|
||||||
|
- `TextBox` with placeholder "Add a task…"
|
||||||
|
- On Enter: dispatch new task (see ViewModel spec)
|
||||||
|
|
||||||
|
Task list:
|
||||||
|
- `ItemsControl` → `Border Classes="task-row"` per task
|
||||||
|
- Row content: `Grid` with columns `Auto,*,Auto`
|
||||||
|
- Left: `Ellipse Classes="task-check"` (toggles `done` class on completion) — use a `Button` with a templated Ellipse for keyboard support
|
||||||
|
- Middle: `StackPanel` vertical
|
||||||
|
- Title: `TextBlock` 14px, strike-through when done
|
||||||
|
- Meta row: `StackPanel` horizontal with 8px gap, children:
|
||||||
|
- `Border Classes="chip {status}"` (status chip — Running / Review / Error / Queued / Idle)
|
||||||
|
- `Border Classes="chip"` with list name
|
||||||
|
- `Border Classes="chip"` with mono branch name (e.g. `agent/auth-pool`)
|
||||||
|
- `Border Classes="chip"` with diff stats (`+142 −86`)
|
||||||
|
- Live tail of latest agent output when running — use `TextTrimming="CharacterEllipsis"` in a fixed-width container
|
||||||
|
- Right: `Button Classes="icon-btn"` (star)
|
||||||
|
- Selection: toggle `selected` class; add a `Rectangle` with `Width=2` as the left accent bar (child of the task-row Border)
|
||||||
|
|
||||||
|
### Details island (right)
|
||||||
|
|
||||||
|
Shown when a task is selected. Sections, top to bottom:
|
||||||
|
|
||||||
|
1. **Header**: task title (editable — `TextBox` with no visible border, `FontSize=18`), list chip, delete icon button
|
||||||
|
2. **Agent strip** — `Border Classes="agent-strip {status}"`:
|
||||||
|
- Row 1: status indicator dot + status label ("Running" / "Review" / etc.) + model name ("claude-sonnet-4.5") + turns + tokens + elapsed
|
||||||
|
- Row 2: Worktree path (mono, truncating)
|
||||||
|
- Row 3: Branch → Base ("agent/auth-pool ← main") + commit count + diff stats
|
||||||
|
- Buttons: "Open diff" / "Worktree" / "Stop" (when running) / "Approve & merge" (when review)
|
||||||
|
3. **Session output** — `Border Classes="terminal"` with a `ScrollViewer` auto-scrolled to bottom:
|
||||||
|
- Each line is a `TextBlock Classes="log-{kind}"` — kinds: sys, tool, claude, stdout, stderr, done, msg
|
||||||
|
- Below it, a prompt input: `[you]` prefix + `TextBox` to send messages to the agent
|
||||||
|
4. **Subtasks** — `ItemsControl` of checkbox + text rows
|
||||||
|
5. **Notes** — multi-line `TextBox`, `AcceptsReturn="True"`
|
||||||
|
6. **Metadata** — created date, last activity, tags (readonly chips)
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
Two modals in the reference: **Diff** and **Worktree**. Use `Window` with `WindowStartupLocation="CenterOwner"` and a scrim (`Border` over the main window with `Background="#BF030504"` + blur via `OpacityMask` or a child `Grid`). Or use `Dialog` if your shell has one.
|
||||||
|
|
||||||
|
**Diff modal**: left sidebar of files (each with `+N −N` stats), right pane with syntax-colored hunks. Use two `ListBox`-style panels side-by-side. For lines: `del` = red tinted, `add` = green tinted, `ctx` = neutral. Left gutter columns: old line number, new line number, sign (`+` / `−` / space).
|
||||||
|
|
||||||
|
**Worktree modal**: folder tree with `M` (modified) / `A` (added) badges. `TreeView` fits naturally.
|
||||||
|
|
||||||
|
### Status mapping
|
||||||
|
|
||||||
|
| Status | Chip color | Icon | When |
|
||||||
|
|---|---|---|---|
|
||||||
|
| idle | TextMute | circle | Task created, agent not dispatched |
|
||||||
|
| queued | Sage | dots | Agent queued behind others |
|
||||||
|
| running | Accent (moss) | pulse dot | Agent actively working |
|
||||||
|
| review | Peat | eye | Agent finished; awaiting approval |
|
||||||
|
| error | Blood | exclamation | Agent failed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State & interactions
|
||||||
|
|
||||||
|
### Task model (MVVM)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class TaskItem : ReactiveObject {
|
||||||
|
string Id, Title, List;
|
||||||
|
bool Done, Starred, MyDay;
|
||||||
|
DateTime? Due, Created;
|
||||||
|
string Notes;
|
||||||
|
List<string> Tags;
|
||||||
|
List<SubTask> Subtasks;
|
||||||
|
AgentState Agent; // null if not dispatched
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AgentState : ReactiveObject {
|
||||||
|
AgentStatus Status; // Idle | Queued | Running | Review | Error
|
||||||
|
string Model, Worktree, Branch, BaseBranch;
|
||||||
|
int Commits, Turns, Tokens;
|
||||||
|
DiffStats Diff; // Files, Additions, Deletions
|
||||||
|
DateTime? StartedAt, FinishedAt;
|
||||||
|
ObservableCollection<LogLine> Log;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key interactions
|
||||||
|
|
||||||
|
- **Toggle done**: click checkbox → flip `Done`, animate strike-through (0.2s ease-out)
|
||||||
|
- **Select task**: click row → set `SelectedTask`; details island rebinds
|
||||||
|
- **Add task**: Enter in the add-task textbox → prepend new task; scroll list to top; 0.3s fade-in animation on the new row (use `Animation` with opacity + `TranslateTransform.Y`)
|
||||||
|
- **Dispatch agent**: "Start agent" button in Details → sets `Agent.Status = Running`, appends sys log "Agent dispatched."
|
||||||
|
- **Stop agent**: → `Status = Review` (or `Error` on failure), appends sys log
|
||||||
|
- **Send prompt**: Enter in prompt input → append `[you] {msg}` to log
|
||||||
|
- **Open diff / worktree**: opens modal; Esc closes
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
- `/` focuses the search box in the Lists island
|
||||||
|
- `Cmd/Ctrl+N` focuses add-task
|
||||||
|
- `Space` toggles done on selected row
|
||||||
|
- `Esc` closes any open modal
|
||||||
|
|
||||||
|
### Animations
|
||||||
|
|
||||||
|
- Task-row hover: background transition 0.1s
|
||||||
|
- Task-row add: 0.3s opacity + slight Y-slide
|
||||||
|
- Task-row complete: 0.25s strike-through + fade to `done` styling
|
||||||
|
- Running status dot: infinite pulse (opacity 0.4 → 1.0, 1.2s)
|
||||||
|
- Modal open: 0.18s opacity + scale (0.98 → 1.0)
|
||||||
|
- Backdrop: 0.15s opacity fade
|
||||||
|
|
||||||
|
### Responsive
|
||||||
|
|
||||||
|
- `< 1100px`: hide Details island; details open as a transient panel or modal on task select
|
||||||
|
- `< 780px`: hide Lists island; use a hamburger drawer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design tokens (reference)
|
||||||
|
|
||||||
|
All final values live in `Tokens.axaml`. Reproduced here for reading:
|
||||||
|
|
||||||
|
**Surfaces**: `#0A0E0C` void · `#0D1311` deep · `#161D1A` surface · `#1C2422` surface-2 · `#222B28` surface-3 · `#2A3330` line
|
||||||
|
|
||||||
|
**Text**: `#E4EBE4` primary · `#9AA8A0` dim · `#6B7973` mute · `#4A5550` faint
|
||||||
|
|
||||||
|
**Accents**: `#7C9166` moss (primary) · `#8B9D7A` sage · `#D4A574` peat · `#C87060` blood
|
||||||
|
|
||||||
|
**Spacing**: 4, 8, 12, 14 (island gap), 18, 24
|
||||||
|
|
||||||
|
**Corner radii**: 14 (island) · 12 (modal) · 10 (chip) · 8 (task row, input) · 6 (button) · 999 (pill)
|
||||||
|
|
||||||
|
**Typography**: Inter Tight (sans), JetBrains Mono (mono). Scale: 10 (eyebrow) / 11 (mono, micro) / 13 (body) / 14 (task title) / 18 (h3) / 24 (h2) / 32 (h1)
|
||||||
|
|
||||||
|
**Shadows**:
|
||||||
|
- Island: `0 20 40 #59000000, 0 2 4 #4D000000`
|
||||||
|
- Modal: `0 40 80 #B2000000`
|
||||||
|
|
||||||
|
**Motion**: 120ms (fast) / 180ms (base) / 300ms (slow). Easing: cubic-bezier(0.4, 0, 0.2, 1) — use Avalonia's `CubicEaseOut` or a custom `SplineEasing`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance checklist
|
||||||
|
|
||||||
|
- [ ] Three-island layout with correct spacing and grid collapse at <1100px
|
||||||
|
- [ ] Lists sidebar with icons, counts, search, active state
|
||||||
|
- [ ] Task rows with checkbox, title, meta chips (status/list/branch/diff), star
|
||||||
|
- [ ] Task selection updates Details island
|
||||||
|
- [ ] Agent strip shows status, model, turns, tokens, elapsed, worktree, branch
|
||||||
|
- [ ] Session terminal renders all log kinds with distinct colors, auto-scrolls, accepts prompt input
|
||||||
|
- [ ] Diff modal with file sidebar and tinted add/del lines
|
||||||
|
- [ ] Worktree modal with M/A badges
|
||||||
|
- [ ] Status chip tints match the spec
|
||||||
|
- [ ] Fonts: Inter Tight + JetBrains Mono packed and applied
|
||||||
|
- [ ] Motion: task add/toggle, running pulse, modal open, hover transitions
|
||||||
|
- [ ] Keyboard shortcuts wired
|
||||||
|
|
||||||
|
## Questions / contact
|
||||||
|
|
||||||
|
The HTML reference is the source of truth for any visual ambiguity. Open `ClaudeDo-standalone.html` and inspect directly.
|
||||||
188
docs/UI Rewrite/design_handoff_claudedo/Tokens.axaml
Normal file
188
docs/UI Rewrite/design_handoff_claudedo/Tokens.axaml
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<!--
|
||||||
|
ClaudeDo design tokens for Avalonia.
|
||||||
|
Merge into App.axaml via <Application.Resources><ResourceDictionary.MergedDictionaries>.
|
||||||
|
All colors are sRGB hex. Accent uses a single hue (88 = moss); swap to 40 for peat, 180 for sea.
|
||||||
|
-->
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- BASE PALETTE -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Void / deep / surfaces (windowpane layering, dark-first) -->
|
||||||
|
<Color x:Key="VoidColor">#FF0A0E0C</Color>
|
||||||
|
<Color x:Key="DeepColor">#FF0D1311</Color>
|
||||||
|
<Color x:Key="SurfaceColor">#FF161D1A</Color>
|
||||||
|
<Color x:Key="Surface2Color">#FF1C2422</Color>
|
||||||
|
<Color x:Key="Surface3Color">#FF222B28</Color>
|
||||||
|
<Color x:Key="LineColor">#FF2A3330</Color>
|
||||||
|
<Color x:Key="LineBrightColor">#FF3A4542</Color>
|
||||||
|
|
||||||
|
<!-- Text scale -->
|
||||||
|
<Color x:Key="TextColor">#FFE4EBE4</Color>
|
||||||
|
<Color x:Key="TextDimColor">#FF9AA8A0</Color>
|
||||||
|
<Color x:Key="TextMuteColor">#FF6B7973</Color>
|
||||||
|
<Color x:Key="TextFaintColor">#FF4A5550</Color>
|
||||||
|
|
||||||
|
<!-- Accent family (moss / sage / peat / blood) -->
|
||||||
|
<Color x:Key="MossColor">#FF4A6B4A</Color>
|
||||||
|
<Color x:Key="MossBrightColor">#FF6B8E6B</Color>
|
||||||
|
<Color x:Key="SageColor">#FF8B9D7A</Color>
|
||||||
|
<Color x:Key="PeatColor">#FFD4A574</Color>
|
||||||
|
<Color x:Key="PeatSoftColor">#FFB88D5E</Color>
|
||||||
|
<Color x:Key="BloodColor">#FFC87060</Color>
|
||||||
|
|
||||||
|
<!-- Primary accent — equivalent to oklch(58% 0.08 88) -->
|
||||||
|
<Color x:Key="AccentColor">#FF7C9166</Color>
|
||||||
|
<Color x:Key="AccentDimColor">#FF64785A</Color>
|
||||||
|
<Color x:Key="AccentSoftColor">#FF3E4B39</Color>
|
||||||
|
<Color x:Key="AccentGlowColor">#387C9166</Color> <!-- 22% alpha -->
|
||||||
|
|
||||||
|
<!-- Status colors -->
|
||||||
|
<Color x:Key="StatusRunningColor">#FF7C9166</Color>
|
||||||
|
<Color x:Key="StatusReviewColor">#FFD4A574</Color>
|
||||||
|
<Color x:Key="StatusErrorColor">#FFC87060</Color>
|
||||||
|
<Color x:Key="StatusQueuedColor">#FF8B9D7A</Color>
|
||||||
|
<Color x:Key="StatusIdleColor">#FF6B7973</Color>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- BRUSHES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="VoidBrush" Color="{StaticResource VoidColor}" />
|
||||||
|
<SolidColorBrush x:Key="DeepBrush" Color="{StaticResource DeepColor}" />
|
||||||
|
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}" />
|
||||||
|
<SolidColorBrush x:Key="Surface2Brush" Color="{StaticResource Surface2Color}" />
|
||||||
|
<SolidColorBrush x:Key="Surface3Brush" Color="{StaticResource Surface3Color}" />
|
||||||
|
<SolidColorBrush x:Key="LineBrush" Color="{StaticResource LineColor}" />
|
||||||
|
<SolidColorBrush x:Key="LineBrightBrush" Color="{StaticResource LineBrightColor}" />
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}" />
|
||||||
|
<SolidColorBrush x:Key="TextDimBrush" Color="{StaticResource TextDimColor}" />
|
||||||
|
<SolidColorBrush x:Key="TextMuteBrush" Color="{StaticResource TextMuteColor}" />
|
||||||
|
<SolidColorBrush x:Key="TextFaintBrush" Color="{StaticResource TextFaintColor}" />
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="MossBrush" Color="{StaticResource MossColor}" />
|
||||||
|
<SolidColorBrush x:Key="MossBrightBrush" Color="{StaticResource MossBrightColor}" />
|
||||||
|
<SolidColorBrush x:Key="SageBrush" Color="{StaticResource SageColor}" />
|
||||||
|
<SolidColorBrush x:Key="PeatBrush" Color="{StaticResource PeatColor}" />
|
||||||
|
<SolidColorBrush x:Key="PeatSoftBrush" Color="{StaticResource PeatSoftColor}" />
|
||||||
|
<SolidColorBrush x:Key="BloodBrush" Color="{StaticResource BloodColor}" />
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}" />
|
||||||
|
<SolidColorBrush x:Key="AccentDimBrush" Color="{StaticResource AccentDimColor}" />
|
||||||
|
<SolidColorBrush x:Key="AccentSoftBrush" Color="{StaticResource AccentSoftColor}" />
|
||||||
|
<SolidColorBrush x:Key="AccentGlowBrush" Color="{StaticResource AccentGlowColor}" />
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="StatusRunningBrush" Color="{StaticResource StatusRunningColor}" />
|
||||||
|
<SolidColorBrush x:Key="StatusReviewBrush" Color="{StaticResource StatusReviewColor}" />
|
||||||
|
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
|
||||||
|
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
|
||||||
|
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
|
||||||
|
|
||||||
|
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||||
|
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||||
|
<GradientStop Offset="0" Color="#FF05070A" />
|
||||||
|
<GradientStop Offset="0.5" Color="#FF0A0D10" />
|
||||||
|
<GradientStop Offset="1" Color="#FF060A08" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="IslandBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||||
|
<GradientStop Offset="0" Color="{StaticResource SurfaceColor}" />
|
||||||
|
<GradientStop Offset="1" Color="#FF131917" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SPACING -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<x:Double x:Key="SpaceXs">4</x:Double>
|
||||||
|
<x:Double x:Key="SpaceSm">8</x:Double>
|
||||||
|
<x:Double x:Key="SpaceMd">12</x:Double>
|
||||||
|
<x:Double x:Key="SpaceLg">14</x:Double> <!-- island gap -->
|
||||||
|
<x:Double x:Key="SpaceXl">18</x:Double> <!-- island interior padding -->
|
||||||
|
<x:Double x:Key="Space2Xl">24</x:Double>
|
||||||
|
|
||||||
|
<Thickness x:Key="IslandGapMargin">7</Thickness> <!-- half of 14 on each side -->
|
||||||
|
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
|
||||||
|
<Thickness x:Key="IslandBodyPadding">14</Thickness>
|
||||||
|
<Thickness x:Key="WindowBodyPadding">14</Thickness>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CORNERS & BORDERS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<CornerRadius x:Key="IslandCornerRadius">14</CornerRadius>
|
||||||
|
<CornerRadius x:Key="ButtonCornerRadius">6</CornerRadius>
|
||||||
|
<CornerRadius x:Key="ChipCornerRadius">10</CornerRadius>
|
||||||
|
<CornerRadius x:Key="PillCornerRadius">999</CornerRadius>
|
||||||
|
<CornerRadius x:Key="InputCornerRadius">8</CornerRadius>
|
||||||
|
<CornerRadius x:Key="ModalCornerRadius">12</CornerRadius>
|
||||||
|
|
||||||
|
<Thickness x:Key="HairlineBorder">1</Thickness>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TYPOGRAPHY -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Pack these fonts with the app (use Avalonia's FontFamily='avares://...#Family Name' syntax).
|
||||||
|
Sans: Inter Tight (display) + Inter (body fallback)
|
||||||
|
Mono: JetBrains Mono
|
||||||
|
-->
|
||||||
|
<FontFamily x:Key="SansFont">Inter Tight, Inter, Segoe UI, -apple-system, sans-serif</FontFamily>
|
||||||
|
<FontFamily x:Key="MonoFont">JetBrains Mono, IBM Plex Mono, Cascadia Mono, Consolas, monospace</FontFamily>
|
||||||
|
|
||||||
|
<!-- Type scale -->
|
||||||
|
<x:Double x:Key="FontSizeEyebrow">10</x:Double> <!-- uppercase label, 0.14em tracking -->
|
||||||
|
<x:Double x:Key="FontSizeMono">11</x:Double> <!-- chips, log lines, filepaths -->
|
||||||
|
<x:Double x:Key="FontSizeMicro">11</x:Double> <!-- meta rows -->
|
||||||
|
<x:Double x:Key="FontSizeBody">13</x:Double>
|
||||||
|
<x:Double x:Key="FontSizeTaskTitle">14</x:Double>
|
||||||
|
<x:Double x:Key="FontSizeH3">18</x:Double>
|
||||||
|
<x:Double x:Key="FontSizeH2">24</x:Double> <!-- island titles ("My Day") -->
|
||||||
|
<x:Double x:Key="FontSizeH1">32</x:Double>
|
||||||
|
|
||||||
|
<!-- Common text styles -->
|
||||||
|
<Style x:Key="EyebrowText" Selector="TextBlock.eyebrow">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="{StaticResource FontSizeEyebrow}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="LetterSpacing" Value="1.4" />
|
||||||
|
<Setter Property="TextTransform" Value="Uppercase" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="MonoText" Selector="TextBlock.mono">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="IslandTitle" Selector="TextBlock.island-title">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource SansFont}" />
|
||||||
|
<Setter Property="FontSize" Value="{StaticResource FontSizeH2}" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SHADOWS (use on Island Border via BoxShadow) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<BoxShadows x:Key="IslandShadow">0 20 40 0 #59000000, 0 2 4 0 #4D000000</BoxShadows>
|
||||||
|
<BoxShadows x:Key="ModalShadow">0 40 80 0 #B2000000</BoxShadows>
|
||||||
|
<BoxShadows x:Key="SubtleShadow">0 2 4 0 #33000000</BoxShadows>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- MOTION -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<Duration x:Key="MotionFast">0:0:0.12</Duration>
|
||||||
|
<Duration x:Key="MotionBase">0:0:0.18</Duration>
|
||||||
|
<Duration x:Key="MotionSlow">0:0:0.30</Duration>
|
||||||
|
|
||||||
|
<!-- Standard easing: cubic-bezier(0.4, 0, 0.2, 1) — equivalent to Avalonia's CubicEaseOut for most UI -->
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
374
docs/UI Rewrite/design_handoff_claudedo/app.jsx
Normal file
374
docs/UI Rewrite/design_handoff_claudedo/app.jsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
// App shell + Tweaks panel + Windows chrome
|
||||||
|
const { useState, useEffect, useRef, useMemo } = window.React;
|
||||||
|
|
||||||
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
"accentHue": 88,
|
||||||
|
"islandGap": 14,
|
||||||
|
"islandRadius": 14,
|
||||||
|
"grainOpacity": 0.035,
|
||||||
|
"density": "comfy",
|
||||||
|
"sidebarWidth": 260
|
||||||
|
}/*EDITMODE-END*/;
|
||||||
|
|
||||||
|
const HUE_PRESETS = [
|
||||||
|
{ name: 'Moss', h: 88 },
|
||||||
|
{ name: 'Sea', h: 200 },
|
||||||
|
{ name: 'Peat', h: 60 },
|
||||||
|
{ name: 'Heather', h: 310 },
|
||||||
|
{ name: 'Rust', h: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TweaksPanel = ({ open, onClose, tweaks, setTweaks }) => {
|
||||||
|
const update = (k, v) => {
|
||||||
|
const next = { ...tweaks, [k]: v };
|
||||||
|
setTweaks(next);
|
||||||
|
try {
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [k]: v } }, '*');
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={`tweaks-panel ${open ? 'open' : ''}`}>
|
||||||
|
<div className="tweaks-head">
|
||||||
|
<div className="tweaks-title">Tweaks</div>
|
||||||
|
<button className="tweaks-close" onClick={onClose}><Icon name="close" size={12} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<div className="tweak-row" style={{ paddingBottom: 4 }}>
|
||||||
|
<span className="label">Accent</span>
|
||||||
|
<span className="val">H {tweaks.accentHue}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hue-swatches">
|
||||||
|
{HUE_PRESETS.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.h}
|
||||||
|
className={`hue-swatch ${tweaks.accentHue === p.h ? 'active' : ''}`}
|
||||||
|
style={{ background: `oklch(58% 0.08 ${p.h})` }}
|
||||||
|
title={p.name}
|
||||||
|
onClick={() => update('accentHue', p.h)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="360" step="1"
|
||||||
|
value={tweaks.accentHue}
|
||||||
|
onChange={(e) => update('accentHue', +e.target.value)}
|
||||||
|
style={{ flex: 1, marginLeft: 6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tweak-row">
|
||||||
|
<span className="label">Gap</span>
|
||||||
|
<input type="range" min="4" max="28" step="1" value={tweaks.islandGap}
|
||||||
|
onChange={(e) => update('islandGap', +e.target.value)} />
|
||||||
|
<span className="val">{tweaks.islandGap}px</span>
|
||||||
|
</div>
|
||||||
|
<div className="tweak-row">
|
||||||
|
<span className="label">Radius</span>
|
||||||
|
<input type="range" min="0" max="24" step="1" value={tweaks.islandRadius}
|
||||||
|
onChange={(e) => update('islandRadius', +e.target.value)} />
|
||||||
|
<span className="val">{tweaks.islandRadius}px</span>
|
||||||
|
</div>
|
||||||
|
<div className="tweak-row">
|
||||||
|
<span className="label">Grain</span>
|
||||||
|
<input type="range" min="0" max="0.12" step="0.005" value={tweaks.grainOpacity}
|
||||||
|
onChange={(e) => update('grainOpacity', +e.target.value)} />
|
||||||
|
<span className="val">{tweaks.grainOpacity.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="tweak-row">
|
||||||
|
<span className="label">Sidebar</span>
|
||||||
|
<input type="range" min="200" max="340" step="4" value={tweaks.sidebarWidth}
|
||||||
|
onChange={(e) => update('sidebarWidth', +e.target.value)} />
|
||||||
|
<span className="val">{tweaks.sidebarWidth}</span>
|
||||||
|
</div>
|
||||||
|
<div className="tweak-row">
|
||||||
|
<span className="label">Density</span>
|
||||||
|
<div className="density-toggle">
|
||||||
|
<button className={tweaks.density === 'comfy' ? 'on' : ''} onClick={() => update('density', 'comfy')}>Comfy</button>
|
||||||
|
<button className={tweaks.density === 'compact' ? 'on' : ''} onClick={() => update('density', 'compact')}>Compact</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Windows chrome
|
||||||
|
const TitleBar = ({ search }) => {
|
||||||
|
return (
|
||||||
|
<div className="titlebar">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 16, height: 16, borderRadius: 4,
|
||||||
|
background: 'linear-gradient(135deg, var(--moss), var(--sage))',
|
||||||
|
display: 'grid', placeItems: 'center',
|
||||||
|
}}>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="#0a0e0c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M2 5l2 2 4-4"/></svg>
|
||||||
|
</div>
|
||||||
|
<span className="titlebar-title">
|
||||||
|
ClaudeDo <span className="bullet">·</span> Rider Island
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="titlebar-controls">
|
||||||
|
<button className="titlebar-btn"><Icon name="min" size={12} /></button>
|
||||||
|
<button className="titlebar-btn"><Icon name="max" size={12} /></button>
|
||||||
|
<button className="titlebar-btn close"><Icon name="close" size={12} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Taskbar = () => {
|
||||||
|
const [clock, setClock] = useState(() => {
|
||||||
|
const n = new Date();
|
||||||
|
return {
|
||||||
|
time: n.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
date: n.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const n = new Date();
|
||||||
|
setClock({
|
||||||
|
time: n.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
date: n.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
const icons = ['windows', 'search', 'folder', 'inbox', 'note', 'calendar'];
|
||||||
|
return (
|
||||||
|
<div className="taskbar">
|
||||||
|
{icons.map((ic, i) => (
|
||||||
|
<div key={i} className={`taskbar-icon ${ic === 'note' ? 'active' : ''}`}>
|
||||||
|
<Icon name={ic} size={16} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="taskbar-clock">
|
||||||
|
<div>{clock.time}</div>
|
||||||
|
<div>{clock.date}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- App ----------
|
||||||
|
const App = () => {
|
||||||
|
const [tweaks, setTweaks] = useState(TWEAK_DEFAULTS);
|
||||||
|
const [tweaksOpen, setTweaksOpen] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState(SEED_TASKS);
|
||||||
|
const [activeList, setActiveList] = useState('myday');
|
||||||
|
const [selectedId, setSelectedId] = useState('t1');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showCompleted, setShowCompleted] = useState(true);
|
||||||
|
const [leavingIds, setLeavingIds] = useState([]);
|
||||||
|
const [enteringIds, setEnteringIds] = useState([]);
|
||||||
|
const [diffTaskId, setDiffTaskId] = useState(null);
|
||||||
|
const [worktreeTaskId, setWorktreeTaskId] = useState(null);
|
||||||
|
|
||||||
|
// Apply CSS tweaks
|
||||||
|
useEffect(() => {
|
||||||
|
const r = document.documentElement;
|
||||||
|
r.style.setProperty('--accent-h', tweaks.accentHue);
|
||||||
|
r.style.setProperty('--island-gap', tweaks.islandGap + 'px');
|
||||||
|
r.style.setProperty('--island-radius', tweaks.islandRadius + 'px');
|
||||||
|
r.style.setProperty('--grain-opacity', tweaks.grainOpacity);
|
||||||
|
r.style.setProperty('--sidebar-w', tweaks.sidebarWidth + 'px');
|
||||||
|
r.style.setProperty('--density', tweaks.density === 'comfy' ? 1 : 0.82);
|
||||||
|
}, [tweaks]);
|
||||||
|
|
||||||
|
// Tweaks host protocol
|
||||||
|
useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const d = e.data;
|
||||||
|
if (!d || typeof d !== 'object') return;
|
||||||
|
if (d.type === '__activate_edit_mode') { setEditMode(true); setTweaksOpen(true); }
|
||||||
|
if (d.type === '__deactivate_edit_mode') { setEditMode(false); setTweaksOpen(false); }
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
try { window.parent.postMessage({ type: '__edit_mode_available' }, '*'); } catch (e) {}
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Counts per list
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c = {};
|
||||||
|
c.myday = tasks.filter((t) => t.myDay && !t.done).length;
|
||||||
|
c.important = tasks.filter((t) => t.starred && !t.done).length;
|
||||||
|
c.planned = tasks.filter((t) => t.due && !t.done).length;
|
||||||
|
c.assigned = 0;
|
||||||
|
c.flagged = 0;
|
||||||
|
c.all = tasks.filter((t) => !t.done).length;
|
||||||
|
SEED_USER_LISTS.forEach((l) => {
|
||||||
|
c[l.id] = tasks.filter((t) => t.list === l.id && !t.done).length;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// Filter tasks
|
||||||
|
const visibleTasks = useMemo(() => {
|
||||||
|
let ts = tasks;
|
||||||
|
if (activeList === 'myday') ts = ts.filter((t) => t.myDay);
|
||||||
|
else if (activeList === 'important') ts = ts.filter((t) => t.starred);
|
||||||
|
else if (activeList === 'planned') ts = ts.filter((t) => t.due);
|
||||||
|
else if (activeList === 'all') ts = ts;
|
||||||
|
else if (activeList === 'assigned' || activeList === 'flagged') ts = [];
|
||||||
|
else ts = ts.filter((t) => t.list === activeList);
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
ts = ts.filter((t) => t.title.toLowerCase().includes(q) || (t.notes || '').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
return ts;
|
||||||
|
}, [tasks, activeList, search]);
|
||||||
|
|
||||||
|
const selected = tasks.find((t) => t.id === selectedId);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const toggleTask = (id) => {
|
||||||
|
setTasks((prev) => prev.map((t) =>
|
||||||
|
t.id === id ? { ...t, done: !t.done, completedAt: !t.done ? new Date().toISOString() : null } : t
|
||||||
|
));
|
||||||
|
};
|
||||||
|
const starTask = (id) => {
|
||||||
|
setTasks((prev) => prev.map((t) => t.id === id ? { ...t, starred: !t.starred } : t));
|
||||||
|
};
|
||||||
|
const updateTask = (next) => {
|
||||||
|
setTasks((prev) => prev.map((t) => t.id === next.id ? next : t));
|
||||||
|
};
|
||||||
|
const deleteTask = (id) => {
|
||||||
|
setLeavingIds((l) => [...l, id]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
setLeavingIds((l) => l.filter((x) => x !== id));
|
||||||
|
if (selectedId === id) setSelectedId(null);
|
||||||
|
}, 280);
|
||||||
|
};
|
||||||
|
const addTask = (title) => {
|
||||||
|
const id = 't' + Date.now();
|
||||||
|
const newTask = {
|
||||||
|
id, title,
|
||||||
|
list: ['myday','important','planned','running','review','all'].includes(activeList) ? 'claudedo' : activeList,
|
||||||
|
myDay: true,
|
||||||
|
starred: false,
|
||||||
|
due: new Date().toISOString(),
|
||||||
|
notes: '',
|
||||||
|
tags: [],
|
||||||
|
subtasks: [],
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
done: false,
|
||||||
|
agent: {
|
||||||
|
status: 'idle',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
worktree: `~/worktrees/${activeList}/new-task-${id.slice(1,6)}`,
|
||||||
|
branch: `agent/new-task-${id.slice(1,6)}`,
|
||||||
|
baseBranch: 'main',
|
||||||
|
commits: 0,
|
||||||
|
diff: { files: 0, additions: 0, deletions: 0 },
|
||||||
|
turns: 0,
|
||||||
|
tokens: 0,
|
||||||
|
log: [{ t: new Date().toISOString(), k: 'sys', m: 'Worktree ready. Agent not yet dispatched.' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setTasks((prev) => [newTask, ...prev]);
|
||||||
|
setEnteringIds((l) => [...l, id]);
|
||||||
|
setSelectedId(id);
|
||||||
|
setTimeout(() => setEnteringIds((l) => l.filter((x) => x !== id)), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentAction = (id, action) => {
|
||||||
|
setTasks((prev) => prev.map((t) => {
|
||||||
|
if (t.id !== id || !t.agent) return t;
|
||||||
|
if (action === 'start') {
|
||||||
|
return { ...t, agent: { ...t.agent, status: 'running', startedAt: new Date().toISOString(),
|
||||||
|
log: [...(t.agent.log || []), { t: new Date().toISOString(), k: 'sys', m: 'Agent dispatched.' }] } };
|
||||||
|
}
|
||||||
|
if (action === 'stop') {
|
||||||
|
return { ...t, agent: { ...t.agent, status: 'review', finishedAt: new Date().toISOString(),
|
||||||
|
log: [...(t.agent.log || []), { t: new Date().toISOString(), k: 'sys', m: 'Stopped by operator.' }] } };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentInput = (id, msg) => {
|
||||||
|
setTasks((prev) => prev.map((t) => {
|
||||||
|
if (t.id !== id || !t.agent) return t;
|
||||||
|
return { ...t, agent: { ...t.agent, log: [...(t.agent.log || []),
|
||||||
|
{ t: new Date().toISOString(), k: 'msg', m: '[you] ' + msg }] } };
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="desktop">
|
||||||
|
<div className="window">
|
||||||
|
<TitleBar />
|
||||||
|
<div className="window-body">
|
||||||
|
<ListsIsland
|
||||||
|
activeList={activeList}
|
||||||
|
setActiveList={(id) => { setActiveList(id); }}
|
||||||
|
counts={counts}
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
/>
|
||||||
|
<TasksIsland
|
||||||
|
tasks={visibleTasks}
|
||||||
|
selectedId={selectedId}
|
||||||
|
setSelected={setSelectedId}
|
||||||
|
onToggle={toggleTask}
|
||||||
|
onStar={starTask}
|
||||||
|
onAdd={addTask}
|
||||||
|
leavingIds={leavingIds}
|
||||||
|
enteringIds={enteringIds}
|
||||||
|
activeList={activeList}
|
||||||
|
showCompleted={showCompleted}
|
||||||
|
setShowCompleted={setShowCompleted}
|
||||||
|
/>
|
||||||
|
<div className="details-col">
|
||||||
|
<DetailsIsland
|
||||||
|
task={selected}
|
||||||
|
onUpdate={updateTask}
|
||||||
|
onDelete={deleteTask}
|
||||||
|
onToggle={toggleTask}
|
||||||
|
onStar={starTask}
|
||||||
|
onAgentAction={agentAction}
|
||||||
|
onAgentInput={agentInput}
|
||||||
|
onOpenDiff={(id) => setDiffTaskId(id)}
|
||||||
|
onOpenWorktree={(id) => setWorktreeTaskId(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Taskbar />
|
||||||
|
|
||||||
|
{diffTaskId && (
|
||||||
|
<DiffModal
|
||||||
|
task={tasks.find((t) => t.id === diffTaskId)}
|
||||||
|
onClose={() => setDiffTaskId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{worktreeTaskId && (
|
||||||
|
<WorktreeModal
|
||||||
|
task={tasks.find((t) => t.id === worktreeTaskId)}
|
||||||
|
onClose={() => setWorktreeTaskId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tweaks: FAB (when edit mode is off) or panel (when toggled) */}
|
||||||
|
{editMode && (
|
||||||
|
<TweaksPanel
|
||||||
|
open={tweaksOpen}
|
||||||
|
onClose={() => setTweaksOpen(false)}
|
||||||
|
tweaks={tweaks}
|
||||||
|
setTweaks={setTweaks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The inner window shouldn't render TitleBar twice — fix:
|
||||||
|
// Actually we want ONE window with one titlebar. Remove the outer TitleBar.
|
||||||
|
const AppFixed = () => <App />;
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<AppFixed />);
|
||||||
228
docs/UI Rewrite/design_handoff_claudedo/data.jsx
Normal file
228
docs/UI Rewrite/design_handoff_claudedo/data.jsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// Seed data for ClaudeDo — Claude agent dispatcher
|
||||||
|
const SEED_LISTS = [
|
||||||
|
{ id: 'myday', kind: 'smart', icon: 'sun', name: 'My Day' },
|
||||||
|
{ id: 'running', kind: 'smart', icon: 'pulse', name: 'Running' },
|
||||||
|
{ id: 'important', kind: 'smart', icon: 'star', name: 'Important' },
|
||||||
|
{ id: 'planned', kind: 'smart', icon: 'calendar', name: 'Planned' },
|
||||||
|
{ id: 'review', kind: 'smart', icon: 'eye', name: 'Needs review' },
|
||||||
|
{ id: 'all', kind: 'smart', icon: 'inbox', name: 'All tasks' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEED_USER_LISTS = [
|
||||||
|
{ id: 'claudedo', icon: 'folder', name: 'ClaudeDo', color: '#6b8e6b' },
|
||||||
|
{ id: 'tuning-web', icon: 'folder', name: 'tuning-web', color: '#d4a574' },
|
||||||
|
{ id: 'api-core', icon: 'folder', name: 'api-core', color: '#8b9d7a' },
|
||||||
|
{ id: 'ops', icon: 'folder', name: 'ops', color: '#7a95a8' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = now;
|
||||||
|
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1);
|
||||||
|
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
|
const mkISO = (mins) => new Date(Date.now() - mins * 60000).toISOString();
|
||||||
|
|
||||||
|
// status: idle | queued | running | review | done | error
|
||||||
|
const SEED_TASKS = [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
title: 'Refactor the auth middleware to use new session store',
|
||||||
|
list: 'api-core',
|
||||||
|
myDay: true, starred: true,
|
||||||
|
due: today.toISOString(),
|
||||||
|
notes: 'Swap the old Redis client for the new pool-aware wrapper. Keep the public API stable.',
|
||||||
|
tags: ['refactor', 'backend'],
|
||||||
|
subtasks: [
|
||||||
|
{ id: 's1', title: 'Audit call sites', done: true },
|
||||||
|
{ id: 's2', title: 'Swap client in middleware.ts', done: true },
|
||||||
|
{ id: 's3', title: 'Update tests', done: false },
|
||||||
|
{ id: 's4', title: 'Run full test suite', done: false },
|
||||||
|
],
|
||||||
|
created: mkISO(120), done: false,
|
||||||
|
agent: {
|
||||||
|
status: 'running',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
worktree: '~/worktrees/api-core/auth-refactor',
|
||||||
|
branch: 'agent/auth-refactor',
|
||||||
|
baseBranch: 'main',
|
||||||
|
startedAt: mkISO(18),
|
||||||
|
commits: 3,
|
||||||
|
diff: { files: 7, additions: 142, deletions: 86 },
|
||||||
|
turns: 24,
|
||||||
|
tokens: 184200,
|
||||||
|
log: [
|
||||||
|
{ t: mkISO(18), k: 'sys', m: 'Session started · worktree: api-core/auth-refactor' },
|
||||||
|
{ t: mkISO(17), k: 'tool', m: 'read_file src/middleware/auth.ts' },
|
||||||
|
{ t: mkISO(17), k: 'tool', m: 'grep "createSessionStore" src/' },
|
||||||
|
{ t: mkISO(16), k: 'msg', m: 'Found 12 call sites across 4 modules. Starting with the middleware.' },
|
||||||
|
{ t: mkISO(14), k: 'tool', m: 'edit_file src/middleware/auth.ts (+48 −22)' },
|
||||||
|
{ t: mkISO(12), k: 'tool', m: 'edit_file src/lib/session/index.ts (+31 −14)' },
|
||||||
|
{ t: mkISO(11), k: 'tool', m: 'run_shell "pnpm test auth"' },
|
||||||
|
{ t: mkISO(10), k: 'stdout', m: ' ✓ auth/basic.test.ts (8)' },
|
||||||
|
{ t: mkISO(10), k: 'stdout', m: ' ✓ auth/session.test.ts (14)' },
|
||||||
|
{ t: mkISO(10), k: 'stdout', m: ' ✗ auth/expiry.test.ts (2 failed)' },
|
||||||
|
{ t: mkISO(9), k: 'msg', m: 'Two expiry tests failing — investigating the TTL calculation.' },
|
||||||
|
{ t: mkISO(6), k: 'tool', m: 'edit_file src/lib/session/ttl.ts (+12 −4)' },
|
||||||
|
{ t: mkISO(5), k: 'tool', m: 'run_shell "pnpm test auth/expiry"' },
|
||||||
|
{ t: mkISO(4), k: 'stdout', m: ' ✓ auth/expiry.test.ts (6)' },
|
||||||
|
{ t: mkISO(3), k: 'msg', m: 'Expiry tests passing. Now running full suite…' },
|
||||||
|
{ t: mkISO(1), k: 'tool', m: 'run_shell "pnpm test"' },
|
||||||
|
{ t: mkISO(0.2), k: 'stdout', m: ' Running 284 tests across 41 files…' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't2',
|
||||||
|
title: 'Add dark mode toggle to settings page',
|
||||||
|
list: 'tuning-web',
|
||||||
|
myDay: true, starred: false,
|
||||||
|
due: today.toISOString(),
|
||||||
|
notes: 'Match the palette from the design system. Persist via localStorage.',
|
||||||
|
tags: ['ui'],
|
||||||
|
subtasks: [],
|
||||||
|
created: mkISO(90), done: false,
|
||||||
|
agent: {
|
||||||
|
status: 'review',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
worktree: '~/worktrees/tuning-web/dark-mode',
|
||||||
|
branch: 'agent/dark-mode',
|
||||||
|
baseBranch: 'main',
|
||||||
|
startedAt: mkISO(45),
|
||||||
|
finishedAt: mkISO(8),
|
||||||
|
commits: 2,
|
||||||
|
diff: { files: 4, additions: 68, deletions: 12 },
|
||||||
|
turns: 14,
|
||||||
|
tokens: 92400,
|
||||||
|
log: [
|
||||||
|
{ t: mkISO(45), k: 'sys', m: 'Session started · worktree: tuning-web/dark-mode' },
|
||||||
|
{ t: mkISO(44), k: 'tool', m: 'read_file src/pages/settings.tsx' },
|
||||||
|
{ t: mkISO(42), k: 'tool', m: 'read_file src/theme/tokens.css' },
|
||||||
|
{ t: mkISO(38), k: 'tool', m: 'edit_file src/pages/settings.tsx (+32 −2)' },
|
||||||
|
{ t: mkISO(30), k: 'tool', m: 'edit_file src/hooks/useTheme.ts (+24 −0)' },
|
||||||
|
{ t: mkISO(22), k: 'tool', m: 'edit_file src/theme/tokens.css (+10 −8)' },
|
||||||
|
{ t: mkISO(14), k: 'tool', m: 'run_shell "pnpm build"' },
|
||||||
|
{ t: mkISO(12), k: 'stdout', m: ' ✓ Built in 4.2s' },
|
||||||
|
{ t: mkISO(10), k: 'tool', m: 'run_shell "pnpm test"' },
|
||||||
|
{ t: mkISO(9), k: 'stdout', m: ' ✓ 182 tests passed' },
|
||||||
|
{ t: mkISO(8), k: 'done', m: 'Ready for review — 2 commits on agent/dark-mode' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't3',
|
||||||
|
title: 'Investigate flaky checkout test',
|
||||||
|
list: 'tuning-web',
|
||||||
|
myDay: true, starred: false,
|
||||||
|
due: today.toISOString(),
|
||||||
|
notes: 'Fails ~1 in 8 runs on CI. Probably a race in the cart hydration.',
|
||||||
|
tags: ['bug', 'tests'],
|
||||||
|
subtasks: [],
|
||||||
|
created: mkISO(200), done: false,
|
||||||
|
agent: {
|
||||||
|
status: 'error',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
worktree: '~/worktrees/tuning-web/flaky-checkout',
|
||||||
|
branch: 'agent/flaky-checkout',
|
||||||
|
baseBranch: 'main',
|
||||||
|
startedAt: mkISO(55),
|
||||||
|
finishedAt: mkISO(40),
|
||||||
|
commits: 0,
|
||||||
|
diff: { files: 0, additions: 0, deletions: 0 },
|
||||||
|
turns: 6,
|
||||||
|
tokens: 28100,
|
||||||
|
log: [
|
||||||
|
{ t: mkISO(55), k: 'sys', m: 'Session started · worktree: tuning-web/flaky-checkout' },
|
||||||
|
{ t: mkISO(54), k: 'tool', m: 'run_shell "pnpm test checkout --repeat 20"' },
|
||||||
|
{ t: mkISO(50), k: 'stdout', m: ' runs: 20 · passes: 18 · failures: 2' },
|
||||||
|
{ t: mkISO(45), k: 'tool', m: 'read_file src/features/checkout/cart.tsx' },
|
||||||
|
{ t: mkISO(42), k: 'tool', m: 'run_shell "pnpm tsc --noEmit"' },
|
||||||
|
{ t: mkISO(41), k: 'stderr', m: ' src/features/checkout/cart.tsx(142,7): TS2339: ...' },
|
||||||
|
{ t: mkISO(40), k: 'error', m: 'Blocked: cannot reproduce the race locally. Paused for operator input.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't4',
|
||||||
|
title: 'Write migration guide for v3 API',
|
||||||
|
list: 'api-core',
|
||||||
|
myDay: true, starred: false,
|
||||||
|
due: tomorrow.toISOString(),
|
||||||
|
notes: '',
|
||||||
|
tags: ['docs'],
|
||||||
|
subtasks: [],
|
||||||
|
created: mkISO(20), done: false,
|
||||||
|
agent: {
|
||||||
|
status: 'idle',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
worktree: '~/worktrees/api-core/v3-migration-guide',
|
||||||
|
branch: 'agent/v3-migration-guide',
|
||||||
|
baseBranch: 'main',
|
||||||
|
commits: 0,
|
||||||
|
diff: { files: 0, additions: 0, deletions: 0 },
|
||||||
|
turns: 0,
|
||||||
|
tokens: 0,
|
||||||
|
log: [
|
||||||
|
{ t: mkISO(20), k: 'sys', m: 'Worktree ready. Agent not yet dispatched.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't5',
|
||||||
|
title: 'Upgrade Postgres client to v16',
|
||||||
|
list: 'ops',
|
||||||
|
myDay: true, starred: false,
|
||||||
|
due: yesterday.toISOString(),
|
||||||
|
notes: 'Coordinate with infra on the rolling restart window.',
|
||||||
|
tags: ['infra'],
|
||||||
|
subtasks: [],
|
||||||
|
created: mkISO(1440), done: false,
|
||||||
|
agent: {
|
||||||
|
status: 'queued',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
worktree: '~/worktrees/ops/pg-16',
|
||||||
|
branch: 'agent/pg-16',
|
||||||
|
baseBranch: 'main',
|
||||||
|
commits: 0,
|
||||||
|
diff: { files: 0, additions: 0, deletions: 0 },
|
||||||
|
turns: 0,
|
||||||
|
tokens: 0,
|
||||||
|
log: [
|
||||||
|
{ t: mkISO(30), k: 'sys', m: 'Queued · waiting for api-core/auth-refactor to complete.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't6',
|
||||||
|
title: 'Fix favicon serving on preview domains',
|
||||||
|
list: 'tuning-web',
|
||||||
|
myDay: true, starred: false,
|
||||||
|
due: null, notes: '', tags: ['bug'],
|
||||||
|
subtasks: [],
|
||||||
|
created: mkISO(300),
|
||||||
|
done: true, completedAt: mkISO(60),
|
||||||
|
agent: {
|
||||||
|
status: 'done',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
worktree: '~/worktrees/tuning-web/favicon',
|
||||||
|
branch: 'agent/favicon',
|
||||||
|
baseBranch: 'main',
|
||||||
|
startedAt: mkISO(90),
|
||||||
|
finishedAt: mkISO(60),
|
||||||
|
commits: 1,
|
||||||
|
diff: { files: 2, additions: 14, deletions: 3 },
|
||||||
|
turns: 8,
|
||||||
|
tokens: 41800,
|
||||||
|
mergedInto: 'main',
|
||||||
|
log: [
|
||||||
|
{ t: mkISO(90), k: 'sys', m: 'Session started' },
|
||||||
|
{ t: mkISO(75), k: 'tool', m: 'edit_file nginx/preview.conf (+8 −3)' },
|
||||||
|
{ t: mkISO(70), k: 'tool', m: 'edit_file public/favicon.ico (+6 −0)' },
|
||||||
|
{ t: mkISO(65), k: 'done', m: 'Merged into main · closed PR #482' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
window.SEED_LISTS = SEED_LISTS;
|
||||||
|
window.SEED_USER_LISTS = SEED_USER_LISTS;
|
||||||
|
window.SEED_TASKS = SEED_TASKS;
|
||||||
123
docs/UI Rewrite/design_handoff_claudedo/icons.jsx
Normal file
123
docs/UI Rewrite/design_handoff_claudedo/icons.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Icons for ClaudeDo (line icons, 1.5px, lucide-ish but original)
|
||||||
|
const Icon = ({ name, size = 16, stroke = 'currentColor' }) => {
|
||||||
|
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke, strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||||||
|
switch (name) {
|
||||||
|
case 'sun': return (
|
||||||
|
<svg {...common}><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4"/></svg>
|
||||||
|
);
|
||||||
|
case 'star': return (
|
||||||
|
<svg {...common}><path d="M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z"/></svg>
|
||||||
|
);
|
||||||
|
case 'star-filled': return (
|
||||||
|
<svg {...common} fill="currentColor"><path d="M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z"/></svg>
|
||||||
|
);
|
||||||
|
case 'calendar': return (
|
||||||
|
<svg {...common}><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
||||||
|
);
|
||||||
|
case 'user': return (
|
||||||
|
<svg {...common}><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
|
||||||
|
);
|
||||||
|
case 'flag': return (
|
||||||
|
<svg {...common}><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg>
|
||||||
|
);
|
||||||
|
case 'inbox': return (
|
||||||
|
<svg {...common}><path d="M3 13h5l1 2h6l1-2h5M3 13l3-8h12l3 8v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||||
|
);
|
||||||
|
case 'folder': return (
|
||||||
|
<svg {...common}><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||||
|
);
|
||||||
|
case 'search': return (
|
||||||
|
<svg {...common}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||||
|
);
|
||||||
|
case 'plus': return (
|
||||||
|
<svg {...common}><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
);
|
||||||
|
case 'bell': return (
|
||||||
|
<svg {...common}><path d="M6 8a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6M10 20a2 2 0 0 0 4 0"/></svg>
|
||||||
|
);
|
||||||
|
case 'repeat': return (
|
||||||
|
<svg {...common}><path d="M17 3l3 3-3 3M20 6H7a4 4 0 0 0-4 4v1M7 21l-3-3 3-3M4 18h13a4 4 0 0 0 4-4v-1"/></svg>
|
||||||
|
);
|
||||||
|
case 'note': return (
|
||||||
|
<svg {...common}><path d="M7 4h7l5 5v11a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM14 4v5h5"/></svg>
|
||||||
|
);
|
||||||
|
case 'tag': return (
|
||||||
|
<svg {...common}><path d="M3 12V5a2 2 0 0 1 2-2h7l9 9-9 9z"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/></svg>
|
||||||
|
);
|
||||||
|
case 'more': return (
|
||||||
|
<svg {...common}><circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/></svg>
|
||||||
|
);
|
||||||
|
case 'sort': return (
|
||||||
|
<svg {...common}><path d="M7 4v16M7 20l-3-3M7 4l-3 3M14 8h7M14 12h5M14 16h3"/></svg>
|
||||||
|
);
|
||||||
|
case 'eye': return (
|
||||||
|
<svg {...common}><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
);
|
||||||
|
case 'grip': return (
|
||||||
|
<svg {...common}><circle cx="9" cy="6" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="18" r="1" fill="currentColor"/><circle cx="15" cy="6" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="18" r="1" fill="currentColor"/></svg>
|
||||||
|
);
|
||||||
|
case 'trash': return (
|
||||||
|
<svg {...common}><path d="M4 7h16M10 11v6M14 11v6M5 7l1 13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-13M9 7V4h6v3"/></svg>
|
||||||
|
);
|
||||||
|
case 'x': return (
|
||||||
|
<svg {...common}><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||||
|
);
|
||||||
|
case 'close': return (
|
||||||
|
<svg {...common} strokeWidth="1.3"><path d="M5 5l10 10M15 5L5 15"/></svg>
|
||||||
|
);
|
||||||
|
case 'min': return (
|
||||||
|
<svg {...common} strokeWidth="1.3"><path d="M5 10h10"/></svg>
|
||||||
|
);
|
||||||
|
case 'max': return (
|
||||||
|
<svg {...common} strokeWidth="1.3"><rect x="5" y="5" width="10" height="10"/></svg>
|
||||||
|
);
|
||||||
|
case 'sliders': return (
|
||||||
|
<svg {...common}><path d="M4 7h10M18 7h2M4 12h4M12 12h8M4 17h14M20 17h0"/><circle cx="14" cy="7" r="2" fill="var(--surface)"/><circle cx="10" cy="12" r="2" fill="var(--surface)"/><circle cx="18" cy="17" r="2" fill="var(--surface)"/></svg>
|
||||||
|
);
|
||||||
|
case 'check': return (
|
||||||
|
<svg {...common}><path d="M4 12l5 5 11-11"/></svg>
|
||||||
|
);
|
||||||
|
case 'windows': return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5L11 4.2v7.3H3zM3 12.5h8v7.3L3 18.5zM12 4l9-1.5V12h-9zM12 12.5h9V20.5L12 19z"/></svg>
|
||||||
|
);
|
||||||
|
case 'pulse': return (
|
||||||
|
<svg {...common}><path d="M3 12h4l2-6 4 12 2-8 2 2h4"/></svg>
|
||||||
|
);
|
||||||
|
case 'branch': return (
|
||||||
|
<svg {...common}><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10M6 13c0-4 12-2 12-4"/></svg>
|
||||||
|
);
|
||||||
|
case 'terminal': return (
|
||||||
|
<svg {...common}><rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="M7 10l3 2-3 2M13 14h5"/></svg>
|
||||||
|
);
|
||||||
|
case 'diff': return (
|
||||||
|
<svg {...common}><path d="M9 3v12M9 15a3 3 0 0 0 3 3h3M15 21v-9M15 9a3 3 0 0 1-3-3H9"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="6" r="2"/></svg>
|
||||||
|
);
|
||||||
|
case 'play': return (
|
||||||
|
<svg {...common} fill="currentColor" stroke="none"><path d="M7 5v14l12-7z"/></svg>
|
||||||
|
);
|
||||||
|
case 'pause': return (
|
||||||
|
<svg {...common}><rect x="7" y="5" width="3.5" height="14" rx="0.5" fill="currentColor" stroke="none"/><rect x="13.5" y="5" width="3.5" height="14" rx="0.5" fill="currentColor" stroke="none"/></svg>
|
||||||
|
);
|
||||||
|
case 'stop': return (
|
||||||
|
<svg {...common}><rect x="6" y="6" width="12" height="12" rx="1" fill="currentColor" stroke="none"/></svg>
|
||||||
|
);
|
||||||
|
case 'folder-open': return (
|
||||||
|
<svg {...common}><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2M3 7v11a2 2 0 0 0 2 2h13.5a2 2 0 0 0 2-1.5L22 10H6a2 2 0 0 0-2 1.5L3 18"/></svg>
|
||||||
|
);
|
||||||
|
case 'external': return (
|
||||||
|
<svg {...common}><path d="M14 4h6v6M10 14l10-10M20 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6"/></svg>
|
||||||
|
);
|
||||||
|
case 'copy': return (
|
||||||
|
<svg {...common}><rect x="8" y="8" width="12" height="12" rx="1.5"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>
|
||||||
|
);
|
||||||
|
case 'send': return (
|
||||||
|
<svg {...common}><path d="M4 12l16-8-5 18-4-8z"/></svg>
|
||||||
|
);
|
||||||
|
case 'cpu': return (
|
||||||
|
<svg {...common}><rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/></svg>
|
||||||
|
);
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Icon = Icon;
|
||||||
650
docs/UI Rewrite/design_handoff_claudedo/islands.jsx
Normal file
650
docs/UI Rewrite/design_handoff_claudedo/islands.jsx
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
// The three islands: ListsIsland, TasksIsland, DetailsIsland
|
||||||
|
const { useState, useEffect, useRef, useMemo } = React;
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
const fmtDate = (iso) => {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
const diff = Math.round((target - today) / 86400000);
|
||||||
|
if (diff === 0) return 'Today';
|
||||||
|
if (diff === 1) return 'Tomorrow';
|
||||||
|
if (diff === -1) return 'Yesterday';
|
||||||
|
if (diff < 0) return `${Math.abs(diff)}d overdue`;
|
||||||
|
if (diff < 7) return d.toLocaleDateString(undefined, { weekday: 'short' });
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
const isToday = (iso) => {
|
||||||
|
if (!iso) return false;
|
||||||
|
const d = new Date(iso); const n = new Date();
|
||||||
|
return d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate();
|
||||||
|
};
|
||||||
|
const isOverdue = (iso) => {
|
||||||
|
if (!iso) return false;
|
||||||
|
const d = new Date(iso); const n = new Date();
|
||||||
|
return d < new Date(n.getFullYear(), n.getMonth(), n.getDate());
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
idle: 'Idle', queued: 'Queued', running: 'Running',
|
||||||
|
review: 'Review', done: 'Done', error: 'Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const relTime = (iso) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const diff = Math.max(0, Date.now() - new Date(iso).getTime());
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
if (s < 60) return s + 's ago';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return m + 'm ago';
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return h + 'h ago';
|
||||||
|
return Math.floor(h / 24) + 'd ago';
|
||||||
|
};
|
||||||
|
|
||||||
|
const logTime = (iso) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Checkbox ----------
|
||||||
|
const Checkbox = ({ done, onToggle, size }) => (
|
||||||
|
<div
|
||||||
|
className={`check ${done ? 'done' : ''}`}
|
||||||
|
style={size ? { width: size, height: size } : undefined}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={done}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M5 12l4.5 4.5L19 7"/></svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- Lists Island ----------
|
||||||
|
const ListsIsland = ({ activeList, setActiveList, counts, search, setSearch }) => {
|
||||||
|
return (
|
||||||
|
<div className="island">
|
||||||
|
<div className="island-header">
|
||||||
|
<div className="island-eyebrow"><span className="dot"/><span>Navigator</span></div>
|
||||||
|
<h2 className="island-title">Lists</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-wrap">
|
||||||
|
<Icon name="search" size={14} />
|
||||||
|
<input
|
||||||
|
placeholder="Search tasks…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="kbd">⌘K</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="island-body">
|
||||||
|
<div className="list-section-label">Smart lists</div>
|
||||||
|
{SEED_LISTS.map((l) => (
|
||||||
|
<div
|
||||||
|
key={l.id}
|
||||||
|
className={`list-item ${activeList === l.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveList(l.id)}
|
||||||
|
>
|
||||||
|
<div className="icon"><Icon name={l.icon} size={15} /></div>
|
||||||
|
<div className="label">{l.name}</div>
|
||||||
|
<div className="count">{counts[l.id] ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="list-section-label">My lists</div>
|
||||||
|
{SEED_USER_LISTS.map((l) => (
|
||||||
|
<div
|
||||||
|
key={l.id}
|
||||||
|
className={`list-item ${activeList === l.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveList(l.id)}
|
||||||
|
>
|
||||||
|
<div className="swatch" style={{ background: l.color }} />
|
||||||
|
<div className="label">{l.name}</div>
|
||||||
|
<div className="count">{counts[l.id] ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button className="new-list-btn">
|
||||||
|
<Icon name="plus" size={14} />
|
||||||
|
<span>New list</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="island-footer" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, var(--moss), var(--sage))',
|
||||||
|
display: 'grid', placeItems: 'center',
|
||||||
|
fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--deep)', fontWeight: 600
|
||||||
|
}}>AK</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text)' }}>Aoife Kelly</div>
|
||||||
|
<div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--text-faint)' }}>rider.island / local</div>
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn" style={{ width: 26, height: 26 }} title="Settings">
|
||||||
|
<Icon name="more" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Tasks Island ----------
|
||||||
|
const TaskRow = ({ task, selected, onSelect, onToggle, onStar, leaving, entering }) => {
|
||||||
|
const [starPulse, setStarPulse] = useState(false);
|
||||||
|
const handleStar = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setStarPulse(true);
|
||||||
|
setTimeout(() => setStarPulse(false), 400);
|
||||||
|
onStar();
|
||||||
|
};
|
||||||
|
const list = SEED_USER_LISTS.find((l) => l.id === task.list);
|
||||||
|
const overdue = isOverdue(task.due) && !task.done;
|
||||||
|
const today = isToday(task.due);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`task ${task.done ? 'done' : ''} ${selected ? 'selected' : ''} ${leaving ? 'leaving' : ''} ${entering ? 'entering' : ''}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<Checkbox done={task.done} onToggle={onToggle} />
|
||||||
|
<div className="task-body">
|
||||||
|
<div className="task-title">{task.title}</div>
|
||||||
|
<div className="task-meta">
|
||||||
|
{task.agent && (
|
||||||
|
<span className={`status-chip ${task.agent.status}`}>
|
||||||
|
<span className={`status-dot ${task.agent.status}`} />
|
||||||
|
{STATUS_LABEL[task.agent.status]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{list && (
|
||||||
|
<span className="chip" style={{ color: list.color }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: 2, background: list.color, display: 'inline-block' }} />
|
||||||
|
{list.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.agent?.branch && (
|
||||||
|
<span className="chip" title={task.agent.branch}>
|
||||||
|
<Icon name="branch" size={10} /> {task.agent.branch.replace('agent/', '')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.agent?.diff && task.agent.diff.files > 0 && (
|
||||||
|
<span className="chip">
|
||||||
|
<span className="diff-stats">
|
||||||
|
<span className="add">+{task.agent.diff.additions}</span>
|
||||||
|
<span className="del">−{task.agent.diff.deletions}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.due && !task.agent && (
|
||||||
|
<span className={`chip ${overdue ? 'overdue' : today ? 'due-today' : ''}`}>
|
||||||
|
<Icon name="calendar" size={10} /> {fmtDate(task.due)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.subtasks && task.subtasks.length > 0 && (
|
||||||
|
<span className="subcount">
|
||||||
|
{task.subtasks.filter((s) => s.done).length}/{task.subtasks.length} steps
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.tags && task.tags.map((t) => <span key={t} className="tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
{task.agent && task.agent.status === 'running' && task.agent.log && task.agent.log.length > 0 && (() => {
|
||||||
|
const last = task.agent.log[task.agent.log.length - 1];
|
||||||
|
return (
|
||||||
|
<div className="task-agent-line">
|
||||||
|
<span className="prompt">›</span>
|
||||||
|
<span className="txt">{last.m}</span>
|
||||||
|
<span className="mini-cursor" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`star-btn ${task.starred ? 'on' : ''} ${starPulse ? 'pulse' : ''}`}
|
||||||
|
onClick={handleStar}
|
||||||
|
title={task.starred ? 'Unstar' : 'Mark important'}
|
||||||
|
>
|
||||||
|
<Icon name={task.starred ? 'star-filled' : 'star'} size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TasksIsland = ({
|
||||||
|
tasks, selectedId, setSelected,
|
||||||
|
onToggle, onStar, onAdd,
|
||||||
|
leavingIds, enteringIds,
|
||||||
|
activeList, showCompleted, setShowCompleted,
|
||||||
|
}) => {
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dateLine = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
const activeTasks = tasks.filter((t) => !t.done);
|
||||||
|
const doneTasks = tasks.filter((t) => t.done);
|
||||||
|
const overdueTasks = activeTasks.filter((t) => isOverdue(t.due));
|
||||||
|
const todayTasks = activeTasks.filter((t) => !isOverdue(t.due));
|
||||||
|
|
||||||
|
const listMeta = SEED_LISTS.find((l) => l.id === activeList) || SEED_USER_LISTS.find((l) => l.id === activeList);
|
||||||
|
const title = activeList === 'myday' ? 'My Day' : (listMeta?.name || 'Tasks');
|
||||||
|
const eyebrow = activeList === 'myday' ? dateLine : `${activeTasks.length} open · ${doneTasks.length} done`;
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newTitle.trim()) {
|
||||||
|
onAdd(newTitle.trim());
|
||||||
|
setNewTitle('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="island">
|
||||||
|
<div className="tasks-head">
|
||||||
|
<div className="tasks-meta">
|
||||||
|
<div>
|
||||||
|
<div className="tasks-date">{activeList === 'myday' ? 'My Day' : 'List'}</div>
|
||||||
|
<h1 className="tasks-title">{title}</h1>
|
||||||
|
<div className="tasks-subtitle">
|
||||||
|
{activeList === 'myday' ? dateLine : eyebrow}
|
||||||
|
<span className="sep">·</span>
|
||||||
|
{activeTasks.length} open
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tasks-actions">
|
||||||
|
<button className="icon-btn" title="Sort"><Icon name="sort" size={15} /></button>
|
||||||
|
<button className={`icon-btn ${showCompleted ? 'active' : ''}`} onClick={() => setShowCompleted((v) => !v)} title="Show completed">
|
||||||
|
<Icon name="eye" size={15} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" title="More"><Icon name="more" size={15} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="add-task" onSubmit={handleSubmit}>
|
||||||
|
<div className="plus">+</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Add a task…"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="hint">ENTER</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="island-body">
|
||||||
|
{overdueTasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="tasks-group-label" style={{ color: 'var(--blood)' }}>Overdue</div>
|
||||||
|
{overdueTasks.map((t) => (
|
||||||
|
<TaskRow
|
||||||
|
key={t.id}
|
||||||
|
task={t}
|
||||||
|
selected={selectedId === t.id}
|
||||||
|
onSelect={() => setSelected(t.id)}
|
||||||
|
onToggle={() => onToggle(t.id)}
|
||||||
|
onStar={() => onStar(t.id)}
|
||||||
|
leaving={leavingIds.includes(t.id)}
|
||||||
|
entering={enteringIds.includes(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{todayTasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
{overdueTasks.length > 0 && <div className="tasks-group-label">Tasks</div>}
|
||||||
|
{todayTasks.map((t) => (
|
||||||
|
<TaskRow
|
||||||
|
key={t.id}
|
||||||
|
task={t}
|
||||||
|
selected={selectedId === t.id}
|
||||||
|
onSelect={() => setSelected(t.id)}
|
||||||
|
onToggle={() => onToggle(t.id)}
|
||||||
|
onStar={() => onStar(t.id)}
|
||||||
|
leaving={leavingIds.includes(t.id)}
|
||||||
|
entering={enteringIds.includes(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTasks.length === 0 && (
|
||||||
|
<div style={{ padding: '40px 24px', textAlign: 'center', color: 'var(--text-faint)' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
|
All clear
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, marginTop: 6 }}>The harbor is calm. Add a task above.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCompleted && doneTasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="tasks-group-label">Completed · {doneTasks.length}</div>
|
||||||
|
{doneTasks.map((t) => (
|
||||||
|
<TaskRow
|
||||||
|
key={t.id}
|
||||||
|
task={t}
|
||||||
|
selected={selectedId === t.id}
|
||||||
|
onSelect={() => setSelected(t.id)}
|
||||||
|
onToggle={() => onToggle(t.id)}
|
||||||
|
onStar={() => onStar(t.id)}
|
||||||
|
leaving={leavingIds.includes(t.id)}
|
||||||
|
entering={enteringIds.includes(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Worktree + Terminal sub-components ----------
|
||||||
|
const WorktreeCard = ({ agent, onOpenDiff, onOpenWorktree }) => {
|
||||||
|
if (!agent) return null;
|
||||||
|
return (
|
||||||
|
<div className="worktree-card">
|
||||||
|
<div className="row">
|
||||||
|
<span className="k">Worktree</span>
|
||||||
|
<span className="v path" title={agent.worktree}>{agent.worktree}</span>
|
||||||
|
<button className="copy-btn" title="Copy path"><Icon name="copy" size={12} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<span className="k">Branch</span>
|
||||||
|
<span className="v">
|
||||||
|
<span className="branch"><Icon name="branch" size={11} /> {agent.branch}</span>
|
||||||
|
<span style={{ color: 'var(--text-faint)', marginLeft: 8 }}>← {agent.baseBranch}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<span className="k">Diff</span>
|
||||||
|
<span className="v">
|
||||||
|
{agent.diff.files > 0 ? (
|
||||||
|
<span className="diff-stats">
|
||||||
|
<span>{agent.diff.files} files</span>
|
||||||
|
<span className="add">+{agent.diff.additions}</span>
|
||||||
|
<span className="del">−{agent.diff.deletions}</span>
|
||||||
|
<span className="bars">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => {
|
||||||
|
const total = agent.diff.additions + agent.diff.deletions || 1;
|
||||||
|
const addShare = Math.round((agent.diff.additions / total) * 5);
|
||||||
|
return <span key={i} className={i < addShare ? 'add' : 'del'} />;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : <span style={{ color: 'var(--text-faint)' }}>No changes yet</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{agent.commits > 0 && (
|
||||||
|
<div className="row">
|
||||||
|
<span className="k">Commits</span>
|
||||||
|
<span className="v">{agent.commits} on branch</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="btn primary grow" onClick={onOpenDiff} disabled={agent.diff.files === 0}>
|
||||||
|
<Icon name="diff" size={12} /> Open diff
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={onOpenWorktree} title="Open worktree folder">
|
||||||
|
<Icon name="folder-open" size={12} /> Worktree
|
||||||
|
</button>
|
||||||
|
<button className="btn icon-only" title="Open in editor">
|
||||||
|
<Icon name="external" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionTerminal = ({ agent, onInput }) => {
|
||||||
|
const bodyRef = useRef(null);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||||
|
}, [agent?.log?.length]);
|
||||||
|
|
||||||
|
if (!agent) return null;
|
||||||
|
const running = agent.status === 'running';
|
||||||
|
const statusLabel = running ? 'LIVE' : STATUS_LABEL[agent.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="terminal">
|
||||||
|
<div className="terminal-head">
|
||||||
|
<div className="dots"><span className="r"/><span className="y"/><span className="g"/></div>
|
||||||
|
<span className="lbl">claude-session · {agent.branch}</span>
|
||||||
|
{running
|
||||||
|
? <span className="live"><span className="d"/>LIVE</span>
|
||||||
|
: <span className="live" style={{ color: 'var(--text-faint)' }}>{statusLabel}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="terminal-body" ref={bodyRef}>
|
||||||
|
{(agent.log || []).map((l, i) => (
|
||||||
|
<div key={i} className={`log-line ${l.k}`}>
|
||||||
|
<span className="ts">{logTime(l.t)}</span>
|
||||||
|
<span className="tag">{l.k === 'msg' ? 'claude' : l.k === 'tool' ? 'tool' : l.k === 'sys' ? 'sys' : l.k === 'stdout' ? 'out' : l.k === 'stderr' ? 'err' : l.k}</span>
|
||||||
|
<span className="m">{l.m}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{running && (
|
||||||
|
<div className="log-line msg">
|
||||||
|
<span className="ts">{logTime(new Date().toISOString())}</span>
|
||||||
|
<span className="tag">claude</span>
|
||||||
|
<span className="m"><span className="cursor-block"/></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, padding: '8px 10px', borderTop: '1px solid var(--line)', background: 'var(--surface-2)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--mono)', color: 'var(--accent)', fontSize: 11, alignSelf: 'center' }}>›</span>
|
||||||
|
<input
|
||||||
|
placeholder={running ? 'Send a message to the agent…' : 'Agent not running'}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && draft.trim()) { onInput(draft); setDraft(''); } }}
|
||||||
|
disabled={!running}
|
||||||
|
style={{ flex: 1, fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--text)' }}
|
||||||
|
/>
|
||||||
|
<button className="btn icon-only" disabled={!draft.trim()}><Icon name="send" size={12} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Details Island ----------
|
||||||
|
const DetailsIsland = ({ task, onUpdate, onDelete, onToggle, onStar, onAgentAction, onOpenDiff, onOpenWorktree, onAgentInput }) => {
|
||||||
|
if (!task) {
|
||||||
|
return (
|
||||||
|
<div className="island">
|
||||||
|
<div className="details-empty">
|
||||||
|
<div>
|
||||||
|
<div className="glyph"><Icon name="note" size={22} /></div>
|
||||||
|
<div className="label">No task selected</div>
|
||||||
|
<div className="hint">Pick a task from the middle<br/>to see its details here.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = SEED_USER_LISTS.find((l) => l.id === task.list);
|
||||||
|
const created = task.created ? new Date(task.created).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—';
|
||||||
|
const due = task.due ? new Date(task.due).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }) : 'None';
|
||||||
|
|
||||||
|
const toggleSub = (sid) => {
|
||||||
|
const next = task.subtasks.map((s) => s.id === sid ? { ...s, done: !s.done } : s);
|
||||||
|
onUpdate({ ...task, subtasks: next });
|
||||||
|
};
|
||||||
|
const addSub = (title) => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
const next = [...(task.subtasks || []), { id: 's' + Date.now(), title: title.trim(), done: false }];
|
||||||
|
onUpdate({ ...task, subtasks: next });
|
||||||
|
};
|
||||||
|
const [subDraft, setSubDraft] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="island">
|
||||||
|
<div className="island-header" style={{ paddingBottom: 10 }}>
|
||||||
|
<div className="island-eyebrow">
|
||||||
|
<span className="dot" />
|
||||||
|
<span>Logbook</span>
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--text-faint)' }}>#{task.id}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="island-title" style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-dim)' }}>
|
||||||
|
{task.agent ? 'Agent task' : 'Task details'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.agent && (
|
||||||
|
<div className="agent-strip">
|
||||||
|
<span className={`status-chip ${task.agent.status}`}>
|
||||||
|
<span className={`status-dot ${task.agent.status}`} />
|
||||||
|
{STATUS_LABEL[task.agent.status]}
|
||||||
|
</span>
|
||||||
|
<div className="meta">
|
||||||
|
<Icon name="cpu" size={10} /> {task.agent.model}
|
||||||
|
<span className="sep">·</span>
|
||||||
|
{task.agent.turns} turns
|
||||||
|
<span className="sep">·</span>
|
||||||
|
{(task.agent.tokens / 1000).toFixed(1)}k tok
|
||||||
|
{task.agent.startedAt && <><span className="sep">·</span>{relTime(task.agent.startedAt)}</>}
|
||||||
|
</div>
|
||||||
|
{task.agent.status === 'running' ? (
|
||||||
|
<button className="btn danger icon-only" onClick={() => onAgentAction(task.id, 'stop')} title="Stop agent"><Icon name="stop" size={12} /></button>
|
||||||
|
) : task.agent.status === 'idle' || task.agent.status === 'error' || task.agent.status === 'queued' ? (
|
||||||
|
<button className="btn primary" onClick={() => onAgentAction(task.id, 'start')}><Icon name="play" size={12} /> {task.agent.status === 'error' ? 'Retry' : 'Dispatch'}</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="island-body">
|
||||||
|
<div className="details-title-row">
|
||||||
|
<Checkbox done={task.done} onToggle={() => onToggle(task.id)} />
|
||||||
|
<textarea
|
||||||
|
className="details-title"
|
||||||
|
value={task.title}
|
||||||
|
onChange={(e) => onUpdate({ ...task, title: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`star-btn ${task.starred ? 'on' : ''}`}
|
||||||
|
style={{ opacity: 1 }}
|
||||||
|
onClick={() => onStar(task.id)}
|
||||||
|
>
|
||||||
|
<Icon name={task.starred ? 'star-filled' : 'star'} size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.agent && (
|
||||||
|
<div className="details-section">
|
||||||
|
<div className="details-section-label">Worktree</div>
|
||||||
|
<WorktreeCard
|
||||||
|
agent={task.agent}
|
||||||
|
onOpenDiff={() => onOpenDiff(task.id)}
|
||||||
|
onOpenWorktree={() => onOpenWorktree(task.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.agent && (
|
||||||
|
<div className="details-section">
|
||||||
|
<div className="details-section-label">
|
||||||
|
Session output
|
||||||
|
<span style={{ marginLeft: 'auto', float: 'right', color: 'var(--text-mute)', fontFamily: 'var(--mono)', fontSize: 10 }}>
|
||||||
|
{(task.agent.log || []).length} lines
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SessionTerminal
|
||||||
|
agent={task.agent}
|
||||||
|
onInput={(msg) => onAgentInput(task.id, msg)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(task.subtasks || []).length > 0 && (
|
||||||
|
<div className="details-section">
|
||||||
|
<div className="details-section-label">Steps · {task.subtasks.filter(s => s.done).length}/{task.subtasks.length}</div>
|
||||||
|
{task.subtasks.map((s) => (
|
||||||
|
<div key={s.id} className={`subtask-row ${s.done ? 'done' : ''}`}>
|
||||||
|
<Checkbox done={s.done} onToggle={() => toggleSub(s.id)} />
|
||||||
|
<div className="label">{s.title}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="subtask-add">
|
||||||
|
<div className="check" style={{ width: 16, height: 16, borderStyle: 'dashed' }} />
|
||||||
|
<input
|
||||||
|
placeholder="Add step"
|
||||||
|
value={subDraft}
|
||||||
|
onChange={(e) => setSubDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { addSub(subDraft); setSubDraft(''); } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="details-section">
|
||||||
|
<div className="meta-row">
|
||||||
|
<span className="key">List</span>
|
||||||
|
<span className="val" style={{ color: list?.color || 'var(--text)' }}>
|
||||||
|
{list ? list.name : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="meta-row">
|
||||||
|
<span className="key">Due</span>
|
||||||
|
<span className={`val ${isOverdue(task.due) && !task.done ? 'peat' : isToday(task.due) ? 'accent' : 'muted'}`}>{due}</span>
|
||||||
|
</div>
|
||||||
|
{!task.agent && (
|
||||||
|
<div className="meta-row">
|
||||||
|
<span className="key">Reminder</span>
|
||||||
|
<span className="val muted">{task.reminder || 'None'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="meta-row">
|
||||||
|
<span className="key">Important</span>
|
||||||
|
<span className={`val ${task.starred ? 'peat' : 'muted'}`}>{task.starred ? 'Starred' : 'No'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="details-section">
|
||||||
|
<div className="details-section-label">Notes</div>
|
||||||
|
<textarea
|
||||||
|
className="notes-area"
|
||||||
|
placeholder="Add a note for the agent…"
|
||||||
|
value={task.notes || ''}
|
||||||
|
onChange={(e) => onUpdate({ ...task, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(task.tags || []).length > 0 && (
|
||||||
|
<div className="details-section" style={{ borderBottom: 0 }}>
|
||||||
|
<div className="details-section-label">Tags</div>
|
||||||
|
<div>
|
||||||
|
{task.tags.map((t) => <span key={t} className="tag-chip">{t}</span>)}
|
||||||
|
<span className="tag-chip" style={{ borderStyle: 'dashed', cursor: 'pointer' }}>+ add</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="island-footer" style={{ display: 'flex', gap: 8, justifyContent: 'space-between' }}>
|
||||||
|
<button className="icon-btn" title="Delete" onClick={() => onDelete(task.id)}>
|
||||||
|
<Icon name="trash" size={14} />
|
||||||
|
</button>
|
||||||
|
<div style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||||
|
Created {created}
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn" title="Close">
|
||||||
|
<Icon name="x" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ListsIsland = ListsIsland;
|
||||||
|
window.TasksIsland = TasksIsland;
|
||||||
|
window.DetailsIsland = DetailsIsland;
|
||||||
201
docs/UI Rewrite/design_handoff_claudedo/modals.jsx
Normal file
201
docs/UI Rewrite/design_handoff_claudedo/modals.jsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Diff modal + Worktree modal
|
||||||
|
const { useState: useStateM, useEffect: useEffectM } = window.React;
|
||||||
|
|
||||||
|
// Fake diff hunks per task
|
||||||
|
const DIFF_HUNKS = {
|
||||||
|
t1: [
|
||||||
|
{ file: 'src/middleware/auth.ts', adds: 48, dels: 22, hunks: [
|
||||||
|
{ header: '@@ -12,7 +12,9 @@ export function authMiddleware(', lines: [
|
||||||
|
{ k: 'ctx', n1: 12, n2: 12, t: ' const session = await getSession(req);' },
|
||||||
|
{ k: 'del', n1: 13, n2: null, t: ' if (!session) return unauthorized();' },
|
||||||
|
{ k: 'del', n1: 14, n2: null, t: ' const user = await lookupUser(session.userId);' },
|
||||||
|
{ k: 'add', n1: null, n2: 13, t: ' if (!session || session.expired) {' },
|
||||||
|
{ k: 'add', n1: null, n2: 14, t: ' return unauthorized("expired_or_missing");' },
|
||||||
|
{ k: 'add', n1: null, n2: 15, t: ' }' },
|
||||||
|
{ k: 'add', n1: null, n2: 16, t: ' const user = await pool.withConnection(c => lookupUser(c, session.userId));' },
|
||||||
|
{ k: 'ctx', n1: 15, n2: 17, t: ' req.user = user;' },
|
||||||
|
{ k: 'ctx', n1: 16, n2: 18, t: ' return next();' },
|
||||||
|
]},
|
||||||
|
{ header: '@@ -42,4 +44,6 @@ export function guard(', lines: [
|
||||||
|
{ k: 'ctx', n1: 42, n2: 44, t: ' return async (req, res, next) => {' },
|
||||||
|
{ k: 'del', n1: 43, n2: null, t: ' const s = await redis.get(req.cookies.sid);' },
|
||||||
|
{ k: 'add', n1: null, n2: 45, t: ' const s = await store.get(req.cookies.sid);' },
|
||||||
|
{ k: 'add', n1: null, n2: 46, t: ' if (s) store.touch(req.cookies.sid);' },
|
||||||
|
{ k: 'ctx', n1: 44, n2: 47, t: ' next();' },
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{ file: 'src/lib/session/index.ts', adds: 31, dels: 14, hunks: [
|
||||||
|
{ header: '@@ -1,8 +1,14 @@', lines: [
|
||||||
|
{ k: 'del', n1: 1, n2: null, t: 'import { createClient } from "redis";' },
|
||||||
|
{ k: 'add', n1: null, n2: 1, t: 'import { SessionStore } from "./store";' },
|
||||||
|
{ k: 'add', n1: null, n2: 2, t: 'import { Pool } from "./pool";' },
|
||||||
|
{ k: 'ctx', n1: 2, n2: 3, t: '' },
|
||||||
|
{ k: 'del', n1: 3, n2: null, t: 'export const redis = createClient({ url: process.env.REDIS_URL });' },
|
||||||
|
{ k: 'add', n1: null, n2: 4, t: 'export const pool = new Pool({ size: 16 });' },
|
||||||
|
{ k: 'add', n1: null, n2: 5, t: 'export const store = new SessionStore(pool);' },
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{ file: 'src/lib/session/ttl.ts', adds: 12, dels: 4, hunks: [] },
|
||||||
|
{ file: 'src/lib/session/store.ts', adds: 38, dels: 0, hunks: [] },
|
||||||
|
],
|
||||||
|
t2: [
|
||||||
|
{ file: 'src/pages/settings.tsx', adds: 32, dels: 2, hunks: [
|
||||||
|
{ header: '@@ -4,6 +4,8 @@ import { Section } from "../ui";', lines: [
|
||||||
|
{ k: 'ctx', n1: 4, n2: 4, t: 'import { useTheme } from "../hooks/useTheme";' },
|
||||||
|
{ k: 'add', n1: null, n2: 5, t: 'import { ThemeToggle } from "../ui/ThemeToggle";' },
|
||||||
|
{ k: 'ctx', n1: 5, n2: 6, t: '' },
|
||||||
|
{ k: 'ctx', n1: 6, n2: 7, t: 'export default function Settings() {' },
|
||||||
|
{ k: 'add', n1: null, n2: 8, t: ' const [theme, setTheme] = useTheme();' },
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{ file: 'src/hooks/useTheme.ts', adds: 24, dels: 0, hunks: [] },
|
||||||
|
{ file: 'src/theme/tokens.css', adds: 10, dels: 8, hunks: [] },
|
||||||
|
{ file: 'src/ui/ThemeToggle.tsx', adds: 26, dels: 2, hunks: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiffModal = ({ task, onClose }) => {
|
||||||
|
useEffectM(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
const files = DIFF_HUNKS[task.id] || [
|
||||||
|
{ file: 'No diff available yet', adds: 0, dels: 0, hunks: [] }
|
||||||
|
];
|
||||||
|
const [activeFile, setActiveFile] = useStateM(0);
|
||||||
|
const current = files[activeFile];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal diff-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Icon name="diff" size={14} />
|
||||||
|
<div>
|
||||||
|
<div className="modal-title">Diff · {task.agent.branch}</div>
|
||||||
|
<div className="modal-sub">
|
||||||
|
{task.agent.worktree} · {files.length} files ·
|
||||||
|
<span className="add" style={{ marginLeft: 6 }}>+{task.agent.diff.additions}</span>
|
||||||
|
<span className="del" style={{ marginLeft: 6 }}>−{task.agent.diff.deletions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button className="btn"><Icon name="external" size={12} /> Open in editor</button>
|
||||||
|
<button className="btn primary"><Icon name="check" size={12} /> Approve & merge</button>
|
||||||
|
<button className="icon-btn" onClick={onClose}><Icon name="close" size={12} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body diff-body">
|
||||||
|
<div className="diff-sidebar">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<div key={f.file} className={`diff-file-tab ${i === activeFile ? 'active' : ''}`} onClick={() => setActiveFile(i)}>
|
||||||
|
<div className="diff-file-name" title={f.file}>{f.file}</div>
|
||||||
|
<div className="diff-file-stats">
|
||||||
|
<span className="add">+{f.adds}</span>
|
||||||
|
<span className="del">−{f.dels}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="diff-view">
|
||||||
|
<div className="diff-file-header">
|
||||||
|
<Icon name="note" size={12} />
|
||||||
|
<span>{current.file}</span>
|
||||||
|
<span style={{ marginLeft: 'auto' }} className="diff-stats">
|
||||||
|
<span className="add">+{current.adds}</span>
|
||||||
|
<span className="del">−{current.dels}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{current.hunks.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-faint)', fontFamily: 'var(--mono)', fontSize: 11 }}>
|
||||||
|
Select a hunk — no detail preview available for this file.
|
||||||
|
</div>
|
||||||
|
) : current.hunks.map((h, hi) => (
|
||||||
|
<div key={hi} className="diff-hunk">
|
||||||
|
<div className="diff-hunk-header">{h.header}</div>
|
||||||
|
{h.lines.map((ln, li) => (
|
||||||
|
<div key={li} className={`diff-line ${ln.k}`}>
|
||||||
|
<span className="ln">{ln.n1 ?? ''}</span>
|
||||||
|
<span className="ln">{ln.n2 ?? ''}</span>
|
||||||
|
<span className="sign">{ln.k === 'add' ? '+' : ln.k === 'del' ? '−' : ' '}</span>
|
||||||
|
<span className="t">{ln.t}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorktreeModal = ({ task, onClose }) => {
|
||||||
|
useEffectM(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const fakeTree = [
|
||||||
|
{ kind: 'dir', path: 'src', children: [
|
||||||
|
{ kind: 'dir', path: 'middleware', children: [
|
||||||
|
{ kind: 'file', path: 'auth.ts', mod: true },
|
||||||
|
]},
|
||||||
|
{ kind: 'dir', path: 'lib/session', children: [
|
||||||
|
{ kind: 'file', path: 'index.ts', mod: true },
|
||||||
|
{ kind: 'file', path: 'ttl.ts', mod: true },
|
||||||
|
{ kind: 'file', path: 'store.ts', added: true },
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{ kind: 'file', path: 'package.json' },
|
||||||
|
{ kind: 'file', path: 'README.md' },
|
||||||
|
];
|
||||||
|
const render = (nodes, depth = 0) => nodes.map((n) => (
|
||||||
|
n.kind === 'dir' ? (
|
||||||
|
<React.Fragment key={n.path}>
|
||||||
|
<div className="tree-row" style={{ paddingLeft: 10 + depth * 14 }}>
|
||||||
|
<Icon name="folder" size={12} /> <span>{n.path}</span>
|
||||||
|
</div>
|
||||||
|
{render(n.children, depth + 1)}
|
||||||
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
|
<div key={n.path} className={`tree-row ${n.mod ? 'mod' : ''} ${n.added ? 'added' : ''}`} style={{ paddingLeft: 10 + depth * 14 }}>
|
||||||
|
<Icon name="note" size={12} />
|
||||||
|
<span>{n.path}</span>
|
||||||
|
{n.mod && <span className="tree-badge mod">M</span>}
|
||||||
|
{n.added && <span className="tree-badge add">A</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal worktree-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<div className="modal-title"><Icon name="folder-open" size={14} /> {task.agent.worktree}</div>
|
||||||
|
<div className="modal-sub">{task.agent.branch} ← {task.agent.baseBranch}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button className="btn"><Icon name="terminal" size={12} /> Open terminal</button>
|
||||||
|
<button className="icon-btn" onClick={onClose}><Icon name="close" size={12} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid var(--line)', fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--text-mute)' }}>
|
||||||
|
Filesystem preview — modified files marked <span style={{ color: 'var(--peat)' }}>M</span>, additions <span style={{ color: 'var(--moss-bright)' }}>A</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 8, fontFamily: 'var(--mono)', fontSize: 12 }}>
|
||||||
|
{render(fakeTree)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.DiffModal = DiffModal;
|
||||||
|
window.WorktreeModal = WorktreeModal;
|
||||||
1383
docs/UI Rewrite/design_handoff_claudedo/styles.css
Normal file
1383
docs/UI Rewrite/design_handoff_claudedo/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
35
docs/open.md
35
docs/open.md
@@ -191,3 +191,38 @@ Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` ma
|
|||||||
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
|
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
|
||||||
|
|
||||||
Punkte 1–3 sind ein realistischer Block für eine Session.
|
Punkte 1–3 sind ein realistischer Block für eine Session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Update — Manual Verification
|
||||||
|
|
||||||
|
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, and `checksums.txt` listing both.
|
||||||
|
|
||||||
|
1. Install a baseline version (e.g. `0.2.x`) normally.
|
||||||
|
2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums.
|
||||||
|
3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`.
|
||||||
|
4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker.
|
||||||
|
5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)".
|
||||||
|
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version.
|
||||||
|
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
|
||||||
|
8. Repeat step 6 with **Cancel** → installer exits without any action.
|
||||||
|
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Sessions — Manual Verification (Plan C UI)
|
||||||
|
|
||||||
|
Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure/styling checks are meaningful.
|
||||||
|
|
||||||
|
1. Create a Manual task with a title and a TODO-ish description.
|
||||||
|
2. Right-click the task → **Open planning Session** — Windows Terminal opens with Claude CLI running (Plan B).
|
||||||
|
3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`.
|
||||||
|
4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge.
|
||||||
|
5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand.
|
||||||
|
6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge.
|
||||||
|
7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard.
|
||||||
|
8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted.
|
||||||
|
|
||||||
|
**Known followups (non-blocking):**
|
||||||
|
- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush.
|
||||||
|
- Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed.
|
||||||
|
|||||||
705
docs/superpowers/plans/2026-04-17-logic-bug-fixes.md
Normal file
705
docs/superpowers/plans/2026-04-17-logic-bug-fixes.md
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
# Logic Bug Fixes 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:** Fix confirmed logic bugs across Worker, App/Ui, and Installer found in the 2026-04-17 three-agent review.
|
||||||
|
|
||||||
|
**Architecture:** Each bug is an isolated change to one or two files. Group by priority (Critical → High → Medium → Info). TDD where the bug is observable via xUnit integration test (Worker, Data); for UI/Installer bugs without test harness, do a focused manual repro and guard with a regression comment referencing the commit.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core + SQLite, SignalR, xUnit (Worker tests only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
**Worker:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — remove premature `RunCreated` broadcast
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — emit `RunCreated` after run row insert
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs` — add slot-collision guard on `RunNow`/`ContinueTask`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — extend quoting to cover whitespace/newline
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs` — regression for newline in system prompt
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs` — regression for RunNow-while-queued
|
||||||
|
|
||||||
|
**Ui:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — guard nullability in `AddTask`, harden `OnTaskUpdated`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — defer `_taskId` assignment until after cancel check
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — init TCS before dialog shown
|
||||||
|
|
||||||
|
**Installer:**
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs` — remove inline start; reject CurrentUser without password
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` — rename-before-extract rollback
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs` — add `removeAppData` parameter
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Steps/WriteConfigStep.cs` — expand `~` in `UiDbPath`
|
||||||
|
- Verify: `src/ClaudeDo.Installer/App.xaml.cs` — confirm Avalonia vs WPF usings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical
|
||||||
|
|
||||||
|
### Task 1: Worker — fix `RunCreated` broadcast ordering (W1)
|
||||||
|
|
||||||
|
Bug: `WorkerHub.RunNow` fires `RunCreated` before the run row is inserted by `RunOnceAsync`. UI can receive an event for a row that does not yet exist.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:35-50`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:234-256` (`RunOnceAsync`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove premature broadcast from WorkerHub**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, replace the body of `RunNow`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task RunNow(string taskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _queue.RunNow(taskId);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new HubException("override slot busy");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
throw new HubException("task not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Emit `RunCreated` inside `RunOnceAsync` after row insert**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, find `RunOnceAsync`. After the `runRepo.AddAsync(run, ct);` block (~line 256), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then remove the existing `await _broadcaster.RunCreated(task.Id, 2, true);` on line 128 (inside the auto-retry block in `RunAsync`) and the `await _broadcaster.RunCreated(taskId, nextRunNumber, false);` on line 219 (in `ContinueAsync`), since `RunOnceAsync` now broadcasts unconditionally.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and run Worker tests**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests`
|
||||||
|
Expected: all existing tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs
|
||||||
|
git commit -m "fix(worker): emit RunCreated after run row exists"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Ui — harden `OnTaskUpdated` against async void crash (U2)
|
||||||
|
|
||||||
|
Bug: `TaskListViewModel.OnTaskUpdated` is `async void` with no try/catch. A DB error escapes to `TaskScheduler.UnobservedTaskException` and can crash the process.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs:328-332`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Wrap handler body in try/catch**
|
||||||
|
|
||||||
|
Replace the existing method with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async void OnTaskUpdated(string taskId)
|
||||||
|
{
|
||||||
|
if (CurrentListId is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RefreshSingleAsync(taskId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
|
||||||
|
git commit -m "fix(ui): swallow DB errors in TaskListViewModel.OnTaskUpdated"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Installer — reject CurrentUser service registration without password (I1)
|
||||||
|
|
||||||
|
Bug: `RegisterServiceStep` passes `obj=.\<user>` to `sc.exe create` with no `password=`. SCM rejects it with exit 5 / 1069 and the user gets an opaque error.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current file**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs` to confirm the exact shape of the `obj=` branch and the outer `StepResult` return.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace CurrentUser branch with early failure**
|
||||||
|
|
||||||
|
Where the step builds `obj=".\\<username>"` for the `CurrentUser` account option, replace it with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (ctx.ServiceAccount == ServiceAccountType.CurrentUser)
|
||||||
|
{
|
||||||
|
return StepResult.Fail(
|
||||||
|
"Service cannot run as Current User without a password. " +
|
||||||
|
"Select 'Local System' or extend ServicePage to capture a password.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the `LocalSystem` branch (which passes `obj= LocalSystem` with no password requirement) unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
|
||||||
|
git commit -m "fix(installer): reject CurrentUser service account without password"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High
|
||||||
|
|
||||||
|
### Task 4: Worker — guard slot collision on `RunNow` and `ContinueTask` (W2)
|
||||||
|
|
||||||
|
Bug: Queue slot and override slot have no guard against operating on the same `taskId`. `TaskRunner.MarkRunningAsync` can overwrite `started_at`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs:59-115`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs` (new)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests;
|
||||||
|
|
||||||
|
public class QueueServiceSlotGuardTests : WorkerTestBase
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RunNow_rejects_task_already_active_in_queue_slot()
|
||||||
|
{
|
||||||
|
var queue = ServiceProvider.GetRequiredService<QueueService>();
|
||||||
|
var task = await SeedAgentTaskAsync(listId: await SeedListAsync(), title: "blocker");
|
||||||
|
|
||||||
|
// Prime queue slot by wake signal.
|
||||||
|
queue.WakeQueue();
|
||||||
|
await WaitForActiveSlotAsync("queue", task.Id);
|
||||||
|
|
||||||
|
// RunNow on the same id must throw InvalidOperationException.
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => queue.RunNow(task.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Helpers `WorkerTestBase`, `SeedAgentTaskAsync`, `SeedListAsync`, `WaitForActiveSlotAsync` exist in the test project — follow the pattern from existing tests.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~QueueServiceSlotGuardTests"`
|
||||||
|
Expected: FAIL — currently `RunNow` succeeds and creates a duplicate slot.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add guard in `RunNow`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Services/QueueService.cs`, inside the `lock (_lock)` block in `RunNow` (~line 69), add before the existing override check:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (_queueSlot?.TaskId == taskId)
|
||||||
|
throw new InvalidOperationException("task is already running in queue slot");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add same guard in `ContinueTask`**
|
||||||
|
|
||||||
|
In the `lock (_lock)` block in `ContinueTask` (~line 97), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (_queueSlot?.TaskId == taskId)
|
||||||
|
throw new InvalidOperationException("task is already running in queue slot");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Services/QueueService.cs tests/ClaudeDo.Worker.Tests/QueueServiceSlotGuardTests.cs
|
||||||
|
git commit -m "fix(worker): guard against same task in queue and override slot"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Worker — quote CLI args with tab/newline/carriage-return (W3)
|
||||||
|
|
||||||
|
Bug: `ClaudeArgsBuilder.Escape` only quotes on space/quote. System prompts with newlines pass through unquoted and corrupt the argument list.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs:56-64`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs` (new or existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
If `ClaudeArgsBuilderTests.cs` does not exist, create it:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests;
|
||||||
|
|
||||||
|
public class ClaudeArgsBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_quotes_system_prompt_with_newline()
|
||||||
|
{
|
||||||
|
var builder = new ClaudeArgsBuilder();
|
||||||
|
var args = builder.Build(new ClaudeRunConfig(
|
||||||
|
Model: null,
|
||||||
|
SystemPrompt: "line1\nline2",
|
||||||
|
AgentPath: null,
|
||||||
|
ResumeSessionId: null));
|
||||||
|
|
||||||
|
Assert.Contains("--append-system-prompt \"line1\\nline2\"", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_quotes_system_prompt_with_tab()
|
||||||
|
{
|
||||||
|
var builder = new ClaudeArgsBuilder();
|
||||||
|
var args = builder.Build(new ClaudeRunConfig(
|
||||||
|
Model: null,
|
||||||
|
SystemPrompt: "col1\tcol2",
|
||||||
|
AgentPath: null,
|
||||||
|
ResumeSessionId: null));
|
||||||
|
|
||||||
|
Assert.Contains("\"col1", args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilderTests"`
|
||||||
|
Expected: FAIL — newline is passed through unquoted.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `Escape` condition and escape newline/tab**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs`, replace `Escape`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string Escape(string value)
|
||||||
|
{
|
||||||
|
if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')
|
||||||
|
|| value.Contains('\t') || value.Contains('\n') || value.Contains('\r'))
|
||||||
|
{
|
||||||
|
var escaped = value
|
||||||
|
.Replace("\\", "\\\\")
|
||||||
|
.Replace("\"", "\\\"")
|
||||||
|
.Replace("\n", "\\n")
|
||||||
|
.Replace("\r", "\\r")
|
||||||
|
.Replace("\t", "\\t");
|
||||||
|
return $"\"{escaped}\"";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilderTests"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs tests/ClaudeDo.Worker.Tests/ClaudeArgsBuilderTests.cs
|
||||||
|
git commit -m "fix(worker): escape newline/tab in CLI args"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Installer — remove inline service start from `RegisterServiceStep` (I2)
|
||||||
|
|
||||||
|
Bug: `RegisterServiceStep` calls `sc.exe start` inline. `StartServiceStep` exists separately. If the update path ever wires both, the service is started twice.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Installer/App.xaml.cs` (pipeline) — ensure `StartServiceStep` is in the fresh-install pipeline
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current pipeline wiring in App.xaml.cs**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Installer/App.xaml.cs` around line 112 to confirm the list of steps passed into `InstallerService`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove inline `sc.exe start` from RegisterServiceStep**
|
||||||
|
|
||||||
|
Delete the block (~lines 72-77) that runs `sc.exe start <service>` when `ctx.AutoStart == true`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `StartServiceStep` to the fresh-install pipeline if missing**
|
||||||
|
|
||||||
|
In `App.xaml.cs`, append `new StartServiceStep(...)` after `RegisterServiceStep` in the step list. Gate its execution internally on `ctx.AutoStart` (it already handles exit code 1056).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs src/ClaudeDo.Installer/App.xaml.cs
|
||||||
|
git commit -m "fix(installer): move service start out of RegisterServiceStep"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Installer — rollback-safe extract in `DownloadAndExtractStep` (I3)
|
||||||
|
|
||||||
|
Bug: Old `app/` and `worker/` are deleted before extraction. If extraction throws, user is left with no binaries and no recovery path.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs:70-95`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current delete/extract sequence**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` around lines 70-95 to identify the exact `Directory.Delete` and `ZipFile.ExtractToDirectory` calls and which `ctx` paths they reference.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace delete-before-extract with rename-then-commit**
|
||||||
|
|
||||||
|
Wrap the delete+extract block:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var appDir = Path.Combine(ctx.InstallRoot, "app");
|
||||||
|
var workDir = Path.Combine(ctx.InstallRoot, "worker");
|
||||||
|
var appBak = appDir + ".bak";
|
||||||
|
var workBak = workDir + ".bak";
|
||||||
|
|
||||||
|
// Stash existing dirs.
|
||||||
|
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||||
|
if (Directory.Exists(workBak)) Directory.Delete(workBak, recursive: true);
|
||||||
|
if (Directory.Exists(appDir)) Directory.Move(appDir, appBak);
|
||||||
|
if (Directory.Exists(workDir)) Directory.Move(workDir, workBak);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ZipFile.ExtractToDirectory(zipPath, ctx.InstallRoot, overwriteFiles: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Roll back to previous binaries.
|
||||||
|
if (Directory.Exists(appDir)) Directory.Delete(appDir, recursive: true);
|
||||||
|
if (Directory.Exists(workDir)) Directory.Delete(workDir, recursive: true);
|
||||||
|
if (Directory.Exists(appBak)) Directory.Move(appBak, appDir);
|
||||||
|
if (Directory.Exists(workBak)) Directory.Move(workBak, workDir);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — drop stash.
|
||||||
|
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||||
|
if (Directory.Exists(workBak)) Directory.Delete(workBak, recursive: true);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
|
||||||
|
git commit -m "fix(installer): rollback-safe extract with .bak stash"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Installer — gate `~/.todo-app` deletion behind explicit consent (I4)
|
||||||
|
|
||||||
|
Bug: Uninstaller always deletes user data (db, logs, configs). Reinstalling a different version silently destroys all tasks.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs:60-80`
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Views/UninstallPage.xaml(.cs)` (or equivalent) — add a checkbox
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read `UninstallRunner.RunAsync` signature**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Installer/Core/UninstallRunner.cs` around lines 1-90 to get current signature.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `removeAppData` parameter to `RunAsync`**
|
||||||
|
|
||||||
|
Change signature to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<UninstallResult> RunAsync(bool removeAppData, CancellationToken ct = default)
|
||||||
|
```
|
||||||
|
|
||||||
|
Guard the deletion:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (removeAppData)
|
||||||
|
{
|
||||||
|
var appData = Paths.Expand("~/.todo-app");
|
||||||
|
if (Directory.Exists(appData))
|
||||||
|
Directory.Delete(appData, recursive: true);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire a "Remove user data" checkbox on the uninstall page**
|
||||||
|
|
||||||
|
In the uninstall view/VM, add `[ObservableProperty] private bool _removeAppData;` (default `false`) and pass it into `RunAsync(RemoveAppData)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Installer/Core/UninstallRunner.cs src/ClaudeDo.Installer/Views/UninstallPage.xaml src/ClaudeDo.Installer/Views/UninstallPage.xaml.cs
|
||||||
|
git commit -m "fix(installer): make user-data deletion on uninstall opt-in"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
### Task 9: Ui — guard `AddTask` against null `CurrentListId` after await (U1)
|
||||||
|
|
||||||
|
Bug: `AddTask` awaits `editor.LoadAgentsAsync`. Between `CanAddTask` and `listRepo.GetByIdAsync(CurrentListId)` on line 164, a concurrent `LoadAsync(null)` could null the id. Compiler warns CS8604.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs:157-170`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Capture `CurrentListId` before the first `await`**
|
||||||
|
|
||||||
|
Replace the start of `AddTask`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
||||||
|
private async Task AddTask()
|
||||||
|
{
|
||||||
|
var listId = CurrentListId;
|
||||||
|
if (listId is null) return;
|
||||||
|
|
||||||
|
string defaultCommitType;
|
||||||
|
using (var context = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var listRepo = new ListRepository(context);
|
||||||
|
var list = await listRepo.GetByIdAsync(listId);
|
||||||
|
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
||||||
|
}
|
||||||
|
|
||||||
|
var editor = _editorFactory();
|
||||||
|
await editor.LoadAgentsAsync(_worker);
|
||||||
|
editor.InitForCreate(listId, defaultCommitType);
|
||||||
|
// …rest unchanged, but use `listId` consistently where CurrentListId was read
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit the rest of the method: replace every subsequent read of `CurrentListId` with `listId`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds with no CS8604 on this method.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
|
||||||
|
git commit -m "fix(ui): capture CurrentListId before await in AddTask"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Ui — defer `_taskId` assignment in `TaskDetailViewModel.LoadAsync` (U3)
|
||||||
|
|
||||||
|
Bug: `_taskId = taskId` is set at line 87, before the previous `_loadCts` is cancelled. If load is cancelled, `_taskId` has been clobbered but `HasWorktree` / `CanWorktreeAction` still reflect the previous task.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs:76-90`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reset stale worktree state when starting a new load**
|
||||||
|
|
||||||
|
Replace the start of `LoadAsync`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task LoadAsync(string taskId)
|
||||||
|
{
|
||||||
|
var oldCts = _loadCts;
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_loadCts = cts;
|
||||||
|
oldCts?.Cancel();
|
||||||
|
oldCts?.Dispose();
|
||||||
|
var ct = cts.Token;
|
||||||
|
|
||||||
|
_taskId = taskId;
|
||||||
|
|
||||||
|
// Clear stale worktree state so buttons don't act on the previous task.
|
||||||
|
HasWorktree = false;
|
||||||
|
WorktreeState = "";
|
||||||
|
BranchName = null;
|
||||||
|
DiffStat = null;
|
||||||
|
WorktreePath = null;
|
||||||
|
OnPropertyChanged(nameof(CanWorktreeAction));
|
||||||
|
|
||||||
|
LiveText = "";
|
||||||
|
_formatter = new StreamLineFormatter();
|
||||||
|
// …rest unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
|
||||||
|
git commit -m "fix(ui): reset stale worktree state on TaskDetail reload"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Ui — initialize TCS before dialog shown in `TaskEditorViewModel` (U4)
|
||||||
|
|
||||||
|
Bug: `ShowAndWaitAsync` creates a fresh `_tcs` only when called. If `Save` fires before `ShowAndWaitAsync` (possible if `ShowDialogAsync` is ever awaited), the result is dropped.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs:72-80, 260-264`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` (same pattern — apply identically)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reset `_tcs` at the start of `InitForCreate` and `InitForEditAsync`**
|
||||||
|
|
||||||
|
In `TaskEditorViewModel.cs`, at the top of `InitForCreate`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void InitForCreate(string listId, string defaultCommitType = "chore")
|
||||||
|
{
|
||||||
|
_tcs = new TaskCompletionSource<TaskEntity?>();
|
||||||
|
_editId = null;
|
||||||
|
// …rest unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
Same first line at the top of `InitForEditAsync` and `InitForEdit`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove re-assignment in `ShowAndWaitAsync`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply the same pattern to `ListEditorViewModel`**
|
||||||
|
|
||||||
|
Mirror the same three edits in `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` (reset TCS in its `InitForCreate` / `InitForEdit`, strip the creation in `ShowAndWaitAsync`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
|
||||||
|
git commit -m "fix(ui): init editor TCS before dialog can complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Installer — expand `~` in `UiDbPath` (I5)
|
||||||
|
|
||||||
|
Bug: `workerCfg.DbPath = Paths.Expand(ctx.DbPath)` but `uiCfg.DbPath = ctx.UiDbPath` is stored as-is. If UI cannot expand `~` at runtime on Windows, DB path is unresolvable.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Installer/Steps/WriteConfigStep.cs:31-34`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Expand UiDbPath symmetrically**
|
||||||
|
|
||||||
|
Change the assignment:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
uiCfg.DbPath = Paths.Expand(ctx.UiDbPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
|
||||||
|
git commit -m "fix(installer): expand ~ in UiDbPath"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Info
|
||||||
|
|
||||||
|
### Task 13: Installer — verify App.xaml.cs WPF-vs-Avalonia usings (I6)
|
||||||
|
|
||||||
|
Suspected bug: `src/ClaudeDo.Installer/App.xaml.cs` uses `System.Windows` (WPF). If the project is Avalonia, wrong base class is inherited.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `src/ClaudeDo.Installer/App.xaml.cs`
|
||||||
|
- Read: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inspect the csproj for the UI framework SDK**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` and look for `<UseWPF>` or `<PackageReference Include="Avalonia" ... />`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Decision fork**
|
||||||
|
|
||||||
|
- If WPF (`<UseWPF>true</UseWPF>`): `System.Windows` is correct. Stop. No fix needed.
|
||||||
|
- If Avalonia: replace `using System.Windows;` with `using Avalonia;` and change `Application` / `StartupEventArgs` / `ExitEventArgs` to Avalonia equivalents (`Avalonia.Application`, lifetime `OnFrameworkInitializationCompleted`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build ClaudeDo.slnx`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit only if changed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Installer/App.xaml.cs
|
||||||
|
git commit -m "fix(installer): use Avalonia application base class"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
Deferred / not fixed in this plan:
|
||||||
|
- `TryScheduleTrampolineDelete` PID-less delay (I6 in review, low severity) — `ping -n 3` is flaky but rarely hit
|
||||||
|
- `AvailableAgents` being `List<T>` instead of `ObservableCollection<T>` (U5/info) — current `OnPropertyChanged` pattern works; revisit only if a bug manifests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- Every Worker bug (W1–W3) has a regression test or tested path.
|
||||||
|
- Every UI fix names the exact file:line and shows the replacement snippet.
|
||||||
|
- Installer Task 3 (I1) does not guess a password-capture UI — it deliberately returns `StepResult.Fail`, leaving the UX change for a later plan.
|
||||||
|
- Task 13 (I6) is a conditional task with a decision fork; no speculative rewrite.
|
||||||
|
- Types are consistent: `RunCreated(taskId, runNumber, isRetry)` in Task 1 matches the existing `HubBroadcaster.RunCreated` signature used at `TaskRunner.cs:128,219`.
|
||||||
209
docs/superpowers/plans/2026-04-20-ui-polish-design-parity.md
Normal file
209
docs/superpowers/plans/2026-04-20-ui-polish-design-parity.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# UI Polish — Design Parity Follow-up
|
||||||
|
|
||||||
|
> Follow-up to the islands rewrite. Closes visible gaps between the current state and the handoff mock. Execute with subagent-driven development; phases B/C/D can run in parallel.
|
||||||
|
|
||||||
|
**Goal:** Bring the rewrite to pixel-level parity with `docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html`.
|
||||||
|
|
||||||
|
**Tech stack:** Avalonia 12, CommunityToolkit.Mvvm. No new dependencies.
|
||||||
|
|
||||||
|
**Reference files:**
|
||||||
|
- Source of truth: `docs/UI Rewrite/design_handoff_claudedo/ClaudeDo-standalone.html`
|
||||||
|
- CSS measurements: `docs/UI Rewrite/design_handoff_claudedo/styles.css`
|
||||||
|
- JSX component structure: `docs/UI Rewrite/design_handoff_claudedo/islands.jsx`, `app.jsx`
|
||||||
|
- Tokens: `src/ClaudeDo.Ui/Design/Tokens.axaml`
|
||||||
|
- Styles: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
||||||
|
|
||||||
|
**Rules for all phases:**
|
||||||
|
- Use existing token brushes (`MossBrush`, `PeatBrush`, `AccentSoftBrush`, etc.) — do NOT hard-code hex.
|
||||||
|
- Use `Classes="foo"` + selectors in `IslandStyles.axaml` for reusable styling; inline AXAML setters for one-off values only.
|
||||||
|
- Icons: use `Projektion.Avalonia` `PathIcon` with `Data="{StaticResource IconKey}"`. Define new `StreamGeometry` resources in `IslandStyles.axaml` under an `<Icons>` section when needed. Pull the SVG paths from the JSX reference.
|
||||||
|
- Read the relevant JSX + CSS file in the handoff before implementing each component — those are the source of truth for exact measurements/paddings/colors.
|
||||||
|
- Do not touch the data layer, Worker, SignalR, or command wiring. This is a view/style-only pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Shell + title bar (sequential, run first)
|
||||||
|
|
||||||
|
One subagent. Small blast radius; prerequisite for the visual "feel."
|
||||||
|
|
||||||
|
### Task A1 — Custom title bar
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` + `.axaml.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (add title-bar styles + window-control icon-button style)
|
||||||
|
|
||||||
|
- [ ] Replace the current title bar Grid with a 3-section layout:
|
||||||
|
- Left: brand block — checkbox-style green glyph + `CLAUDEDO` (mono, uppercase, tracking 1.4, 11px) + separator dot + current-list name eyebrow-style (mono uppercase, `TextDim`). Bind the list name to `Shell.Lists.SelectedList.Name.ToUpperInvariant()`.
|
||||||
|
- Middle: draggable strip (`PointerPressed → BeginMoveDrag`).
|
||||||
|
- Right: three frameless icon buttons (minimize / maximize-restore / close). Close button hover turns `BloodBrush`. Use `PathIcon` with inline `StreamGeometry` for the Lucide-style icons: `Minus`, `Square`, `X` — the exact SVG `d` strings are in `icons.jsx`.
|
||||||
|
- [ ] Title bar height: 36px, background `DeepBrush`, bottom border 1px `LineBrush`.
|
||||||
|
- [ ] Remove the character glyphs currently used for the window controls (`—`, `▢`, `✕`) — use PathIcons instead.
|
||||||
|
- [ ] Commit: `style(ui): custom title bar with brand and window controls`
|
||||||
|
|
||||||
|
### Task A2 — Background + island shadow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (background layer)
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (island shadow adjust)
|
||||||
|
|
||||||
|
- [ ] Under the three-island Grid, add a `Border` filling the whole row with a subtle radial gradient from `DeepBrush` (center) to `VoidBrush` (edges). Use a `RadialGradientBrush` with 2 stops; keep opacity light.
|
||||||
|
- [ ] In `IslandStyles.axaml`, bump the `Border.island` `BoxShadow` to match the token `IslandShadow` value exactly (`0 20 40 #59000000, 0 2 4 #4D000000`). Verify by inspecting the current style — if it's already set, no-op.
|
||||||
|
- [ ] Commit: `style(ui): background gradient and stronger island shadow`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — Lists island polish (parallel with C, D)
|
||||||
|
|
||||||
|
### Task B1 — Icon geometries + eyebrow rename + sections
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (add `StreamGeometry` icon resources at the top)
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` (map `IconKey` strings → resource keys, add section grouping)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
|
||||||
|
|
||||||
|
- [ ] Extract the SVG path `d` strings from `icons.jsx` for: `Sun`, `Activity` (pulse), `Star`, `Calendar`, `Eye`, `Inbox`, `Folder`, `Search`, `Plus`, `MoreHorizontal`. Define each as an `x:Key="Icon.Sun"` `StreamGeometry` in `IslandStyles.axaml`.
|
||||||
|
- [ ] Change Lists eyebrow from `WORKSPACE` to `NAVIGATOR`.
|
||||||
|
- [ ] Add two section-header rows in the ItemsControl: `SMART LISTS` (above items of `Kind=Smart` + `Virtual`) and `MY LISTS` (above items of `Kind=User`). Simplest approach: two separate `ItemsControl`s bound to filtered subsets; or wrap items in a `CollectionViewSource` grouping. Pick the simplest working approach.
|
||||||
|
- [ ] Per-item icon: bind `PathIcon Data="{DynamicResource Icon.{IconKey}}"` via a tiny `IconGeometryConverter` (takes `IconKey` string → looks up resource). Icon color: `TextMute` default; `AccentBrush` (moss) when `IsActive`.
|
||||||
|
- [ ] User-list items: use a 6px circle with `MossBrush` / `PeatBrush` / `SageBrush` dot instead of folder icon (map per list index mod colors, or single color if simpler).
|
||||||
|
- [ ] Active state: remove solid fill. Use `AccentSoftBrush` (~10% moss) + left 2px accent bar + `AccentBrush` icon + `TextBrush` text.
|
||||||
|
- [ ] Commit: `style(ui): lists icons, section headers, active state`
|
||||||
|
|
||||||
|
### Task B2 — Search bar + keyboard hint + footer buttons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (search style + kbd chip style)
|
||||||
|
|
||||||
|
- [ ] Search `TextBox`: wrap in a `Grid ColumnDefinitions="Auto,*,Auto"` — left `PathIcon Data="{Icon.Search}"` (14px, `TextFaint`), middle TextBox, right `Border Classes="kbd"` with `⌘K` (or `Ctrl K` on Win). The `kbd` chip: mono 10px, `Surface2` bg, `LineBrush` border, padding `6,2`, radius 4.
|
||||||
|
- [ ] Under the items list, add:
|
||||||
|
- `+ New list` button — plain icon+text row, `PathIcon Data="{Icon.Plus}"`, hover tint.
|
||||||
|
- User profile row — avatar circle (initials fallback, seed from `Environment.UserName`), name (`Environment.UserName`), subtitle `{MachineName} / local` mono dim, right `PathIcon Data="{Icon.MoreHorizontal}"`.
|
||||||
|
- [ ] Commit: `style(ui): lists search icon, kbd hint, footer actions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C — Tasks island polish (parallel with B, D)
|
||||||
|
|
||||||
|
### Task C1 — Header + add-task row styling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (subtitle format, header toolbar properties)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (kbd-enter, add-task row)
|
||||||
|
|
||||||
|
- [ ] Subtitle format: change from `{open} open · {running} running · {review} in review` to `{Weekday}, {Month} {Day} · {open} open` to match the mock. Keep the running/review counts visible but move them into a right-aligned mono pill row next to the title (or drop if cleaner).
|
||||||
|
- [ ] Eyebrow: keep current `MONTAG · APR. 20` pattern. Title remains list name.
|
||||||
|
- [ ] Right-side icon toolbar: three `Button Classes="icon-btn"` — `Sort` icon, `Eye` icon (toggle completed), `MoreHorizontal`. Icons: pull paths from `icons.jsx`. Wire `Eye` to an `IsShowingCompleted` observable (persist in a private field for now; no DB change).
|
||||||
|
- [ ] Add-task row: wrap the `TextBox` in a `Border` with `Surface2` bg, rounded 8px, 14px padding. Prepend a circular `PathIcon Data="{Icon.Plus}"` (20px circle, `Surface3` bg). Append a `Border Classes="kbd"` with `ENTER` text (only visible when `NewTaskTitle` has focus — bind visibility to `TextBox.IsFocused`).
|
||||||
|
- [ ] Commit: `style(ui): tasks header toolbar and add-task row`
|
||||||
|
|
||||||
|
### Task C2 — Task row chips + states
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (expose a few more flags: `IsOverdue`, `Tags`, `StepsCount`, `StepsCompleted`)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (chip variants, selected accent, done state, live-tail meter)
|
||||||
|
|
||||||
|
- [ ] Chip set per row (ItemsControl or StackPanel):
|
||||||
|
- Status chip (already present) — ensure color maps per Status → token brush (idle/queued/running/review/error).
|
||||||
|
- List chip — small colored bullet (6px circle in `MossBrush` or similar) + list name.
|
||||||
|
- Branch chip — `PathIcon Data="{Icon.GitBranch}"` (12px) + branch name (mono 10px).
|
||||||
|
- Diff chip — `+N` moss + ` ` + `−M` blood.
|
||||||
|
- Tags — one chip per tag (`#refactor` style, `Surface2` bg, mono 10px, `TextDim`).
|
||||||
|
- [ ] Selected state: add 2px `AccentBrush` left border on the row Border when `IsSelected=true` (style selector `Border.task-row.selected`). Background shifts to `AccentSoftBrush`.
|
||||||
|
- [ ] Done state: strike-through title + fade opacity to 0.5. Add `Border.task-row:has(.done)` equivalent via the existing `Done` binding — simpler: a `TextBlock` style selector that flips `TextDecorations`.
|
||||||
|
- [ ] Live-tail row (only visible when `Status == Running` and `LiveTail != null`): a `Border` under the chip row with mono 11px ellipsized text + a slim 3px progress `Rectangle` with `MossBrush`. For now the progress is static 30% — wire it to a future `ProgressFraction` property (leave as 0.3 fallback).
|
||||||
|
- [ ] Ensure `task-row` Border has `Transitions` for `Background` + `Margin` (smooth hover + select).
|
||||||
|
- [ ] Commit: `style(ui): task row chip set, selected/done states, live tail`
|
||||||
|
|
||||||
|
### Task C3 — Section dividers (OVERDUE / TASKS / COMPLETED)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (group the ObservableCollection into sections)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` (group headers)
|
||||||
|
|
||||||
|
- [ ] Add grouping: transform `Items` into three sub-collections:
|
||||||
|
- `OverdueItems` — tasks with `ScheduledFor < Today` and not Done.
|
||||||
|
- `OpenItems` — remaining not-Done tasks.
|
||||||
|
- `CompletedItems` — tasks with `Done=true`.
|
||||||
|
- [ ] Expose as three `ObservableCollection<TaskRowViewModel>` on the VM. Recompute inside `LoadForList`.
|
||||||
|
- [ ] View: three `ItemsControl`s stacked in a `StackPanel`, each preceded by a section header `TextBlock` — `OVERDUE` (only if non-empty), `TASKS`, `COMPLETED · {N}`. Eyebrow style, `TextFaint`.
|
||||||
|
- [ ] Commit: `style(ui): task section dividers overdue/tasks/completed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D — Details island polish (parallel with B, C)
|
||||||
|
|
||||||
|
### Task D1 — Header + task row restyle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (expose `TaskIdBadge` like `#T1`, computed from task id prefix)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
|
||||||
|
|
||||||
|
- [ ] Top header block:
|
||||||
|
- Eyebrow `LOGBOOK` + right-aligned `#T{shortId}` badge (first 3 hex chars of `Task.Id`, mono `TextFaint`).
|
||||||
|
- Title: keep editable title `TextBox` but reduce size and match mock.
|
||||||
|
- [ ] Under header, a new "task strip" row: `Ellipse` checkbox (bound to `Task.Done` toggle) + title + right-aligned star button. This is separate from the editable title (mock shows both title as editable heading AND a task-row-style strip with check/star).
|
||||||
|
- [ ] Commit: `style(ui): details header with logbook eyebrow and task-id badge`
|
||||||
|
|
||||||
|
### Task D2 — Agent strip v2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (add `Turns`, `TokensFormatted`, `ElapsedFormatted`, `DiffAdditions`, `DiffDeletions`, `CommitsOnBranch` if not present — most exist)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (diff meter bar style)
|
||||||
|
|
||||||
|
- [ ] Layout (three rows):
|
||||||
|
- Row 1: pulsing status dot + status label (`RUNNING` etc.) + mono model name + right-aligned stop button (only visible when Running).
|
||||||
|
- Row 2: `WORKTREE` section label + worktree path mono, with a copy-to-clipboard `PathIcon Data="{Icon.Copy}"` button at the end.
|
||||||
|
- Row 3: Branch line — `PathIcon Data="{Icon.GitBranch}"` + branch mono + arrow `←` + `main` + commits count chip.
|
||||||
|
- Row 4: `DIFF` label + `+{additions}` (moss) + `−{deletions}` (blood) + a slim 4px progress-meter `Rectangle` showing additions vs deletions ratio (moss-filled portion).
|
||||||
|
- [ ] Action buttons row: `Open diff`, `Worktree`, external-link `→` (opens file:// to worktree path in OS explorer).
|
||||||
|
- [ ] Agent strip should use `AgentStripStyle.Classes` bound to the running status so colors shift.
|
||||||
|
- [ ] Commit: `style(ui): agent strip with worktree panel and diff meter`
|
||||||
|
|
||||||
|
### Task D3 — Session terminal styling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (terminal header, log-line columns, `LIVE` chip)
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs` (add `TimestampFormatted` property)
|
||||||
|
|
||||||
|
- [ ] Top bar of the terminal `Border`: three colored dots (red/yellow/green, 8px `Ellipse`) + `claude-session · {branch}` mono text + right-aligned `LIVE` chip (moss bg, white text, pulsing animation when a task is actively running).
|
||||||
|
- [ ] Log lines: two-column layout — timestamp (mono 10px, `TextFaint`, fixed 70px width) + kind marker (e.g. `TOOL`, `CLAUDE`, `OUT`) + text. Kind marker uses attribute selector `[Tag=log-tool]`, color-mapped.
|
||||||
|
- [ ] Line number/timestamp: add `TimestampFormatted` to `LogLineViewModel` populated as `DateTime.Now.ToString("HH:mm:ss")` on construction. (If real timestamps arrive via SignalR later, swap source.)
|
||||||
|
- [ ] Ensure auto-scroll still works (existing logic).
|
||||||
|
- [ ] Commit: `style(ui): session terminal header, line columns, LIVE chip`
|
||||||
|
|
||||||
|
### Task D4 — Subtasks, notes, metadata footer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (subtask row style)
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (delete-task command, close-detail command)
|
||||||
|
|
||||||
|
- [ ] Subtasks: each row is a compact `Border` with rounded 6px, hover background. Check is an `Ellipse` matching the task-row style (not default WinForms-style CheckBox). Completed items get strike-through + fade.
|
||||||
|
- [ ] Notes `TextBox`: `Surface2` bg, 12px padding, watermark `Notes...`, auto-saves on `LostFocus` (call repository `Update`).
|
||||||
|
- [ ] Bottom metadata bar (sticky at the bottom of the Details island — anchor via `DockPanel.Dock="Bottom"`):
|
||||||
|
- Left: `PathIcon Data="{Icon.Trash}"` delete button (prompts confirmation before calling `TaskRepository.DeleteAsync`).
|
||||||
|
- Middle: `Created {Month Day}` mono `TextFaint`.
|
||||||
|
- Right: close-details `PathIcon Data="{Icon.X}"` (clears `SelectedTask` on `TasksIslandViewModel`).
|
||||||
|
- [ ] Commit: `style(ui): subtasks, notes, details metadata footer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase A (A1 → A2) [sequential, 1 subagent]
|
||||||
|
↓
|
||||||
|
Phase B, C, D [parallel, 3 subagents, one per phase]
|
||||||
|
↓
|
||||||
|
Final build + smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase A is sequential because it touches `MainWindow.axaml` and `IslandStyles.axaml` root setup.
|
||||||
|
Phases B, C, D each own a distinct island. Only potential conflict: all three add icon geometries to `IslandStyles.axaml`. Mitigation: Phase B is responsible for adding the `StreamGeometry` icon resources (it needs the most). Phases C and D reference those keys without redefining.
|
||||||
|
|
||||||
|
Final pass: run the app, eyeball against the mock, note remaining gaps.
|
||||||
1636
docs/superpowers/plans/2026-04-20-ui-rewrite-islands.md
Normal file
1636
docs/superpowers/plans/2026-04-20-ui-rewrite-islands.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,803 @@
|
|||||||
|
# Continue & Reset Buttons for Failed Tasks — 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:** Add two buttons (Continue, Reset) to the details pane for `Failed` tasks so the user can either nudge the agent to continue or discard the worktree and return the task to `Manual`.
|
||||||
|
|
||||||
|
**Architecture:** Spec is at `docs/superpowers/specs/2026-04-21-continue-and-reset-failed-tasks-design.md`. Backend adds one git-discard helper, one task-repository method, a small orchestration service, and a new hub method `ResetTask`. `ContinueTask` is already wired in the hub. UI adds two commands in `DetailsIslandViewModel` and a button row in `DetailsIslandView`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit 2.5 (integration tests with real SQLite + real git).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `src/ClaudeDo.Worker/Services/TaskResetService.cs` — orchestrates the reset (load task, discard worktree, reset DB row, broadcast).
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs` — integration tests for the orchestration.
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — add `DiscardAsync`.
|
||||||
|
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — add `ResetToManualAsync`.
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `ResetTask` endpoint; DI-inject the new service.
|
||||||
|
- `src/ClaudeDo.Worker/Program.cs` — register `TaskResetService` in DI.
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `ContinueTaskAsync` and `ResetTaskAsync` wrappers.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add observable properties and commands.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` — add button row bound to the new commands.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` — add `DiscardAsync` test.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` — add `ResetToManualAsync` test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `WorktreeManager.DiscardAsync` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`
|
||||||
|
|
||||||
|
`GitService` already exposes `WorktreeRemoveAsync(workingDir, path, force, ct)` and `BranchDeleteAsync(workingDir, branch, force, ct)` — verify via `git grep -n "public async Task WorktreeRemoveAsync\|public async Task BranchDeleteAsync" src/ClaudeDo.Data/Git`. If either is missing, stop and add the git wrapper first.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the failing test**
|
||||||
|
|
||||||
|
Add at the bottom of `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` (before the `Dispose` method):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardAsync_RemovesWorktreeAndBranch_AndSetsStateDiscarded()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
var (task, list) = MakeEntities(repo.RepoDir);
|
||||||
|
var (mgr, db) = await CreateManagerAsync(task, list);
|
||||||
|
|
||||||
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
var worktreePath = ctx.WorktreePath;
|
||||||
|
|
||||||
|
WorktreeEntity wt;
|
||||||
|
using (var readCtx = db.CreateContext())
|
||||||
|
wt = (await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id))!;
|
||||||
|
|
||||||
|
await mgr.DiscardAsync(wt, list.WorkingDir!, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(worktreePath), "worktree directory should be gone");
|
||||||
|
|
||||||
|
using var readCtx2 = db.CreateContext();
|
||||||
|
var row = await new WorktreeRepository(readCtx2).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.NotNull(row);
|
||||||
|
Assert.Equal(WorktreeState.Discarded, row!.State);
|
||||||
|
|
||||||
|
// Branch should no longer exist on the main repo.
|
||||||
|
var branchList = await new GitService().RunForOutputAsync(repo.RepoDir, new[] { "branch", "--list", ctx.BranchName }, CancellationToken.None);
|
||||||
|
Assert.True(string.IsNullOrWhiteSpace(branchList),
|
||||||
|
$"branch {ctx.BranchName} should be deleted, got: {branchList}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note on `RunForOutputAsync`: if `GitService` does not expose a generic run helper, replace the branch-check with a direct `System.Diagnostics.Process` invocation of `git branch --list <branch>` in the test. If such a helper exists with a different name, use it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test, verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal`
|
||||||
|
Expected: FAIL — `DiscardAsync` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `DiscardAsync`**
|
||||||
|
|
||||||
|
Add to `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` after `CommitIfChangedAsync`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task DiscardAsync(WorktreeEntity wt, string workingDir, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Remove the git worktree first; --force drops uncommitted changes (user already confirmed).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: true, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "git worktree remove failed for {Path}", wt.Path);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the branch. If worktree removal succeeded but branch delete fails,
|
||||||
|
// we still record the worktree as Discarded — the folder is gone, and a dangling
|
||||||
|
// branch is recoverable; leaving the DB out of sync is worse.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: true, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "git branch -D {Branch} failed after worktree removal; continuing", wt.BranchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var context = _dbFactory.CreateDbContext();
|
||||||
|
var wtRepo = new WorktreeRepository(context);
|
||||||
|
await wtRepo.SetStateAsync(wt.TaskId, WorktreeState.Discarded, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("Discarded worktree for task {TaskId} (branch {Branch})", wt.TaskId, wt.BranchName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test, verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
|
||||||
|
git commit -m "feat(worker): add WorktreeManager.DiscardAsync for task reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `TaskRepository.ResetToManualAsync` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the failing test**
|
||||||
|
|
||||||
|
Add to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (follow the existing test style in that file — reuse any helpers it already has for creating a list + task):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetToManualAsync_ClearsResultFields_AndSetsStatusManual()
|
||||||
|
{
|
||||||
|
using var db = new DbFixture();
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var listRepo = new ListRepository(ctx);
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
|
||||||
|
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow };
|
||||||
|
await listRepo.AddAsync(list);
|
||||||
|
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = list.Id,
|
||||||
|
Title = "T",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Failed,
|
||||||
|
StartedAt = DateTime.UtcNow.AddMinutes(-5),
|
||||||
|
FinishedAt = DateTime.UtcNow,
|
||||||
|
Result = "boom",
|
||||||
|
};
|
||||||
|
await taskRepo.AddAsync(task);
|
||||||
|
|
||||||
|
await taskRepo.ResetToManualAsync(task.Id);
|
||||||
|
|
||||||
|
using var readCtx = db.CreateContext();
|
||||||
|
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
|
||||||
|
Assert.NotNull(after);
|
||||||
|
Assert.Equal(TaskStatus.Manual, after!.Status);
|
||||||
|
Assert.Null(after.StartedAt);
|
||||||
|
Assert.Null(after.FinishedAt);
|
||||||
|
Assert.Null(after.Result);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test, verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal`
|
||||||
|
Expected: FAIL — `ResetToManualAsync` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `ResetToManualAsync`**
|
||||||
|
|
||||||
|
Add to `src/ClaudeDo.Data/Repositories/TaskRepository.cs` inside the `#region Status transitions` block, after `FlipAllRunningToFailedAsync`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task ResetToManualAsync(string taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||||
|
.SetProperty(t => t.StartedAt, (DateTime?)null)
|
||||||
|
.SetProperty(t => t.FinishedAt, (DateTime?)null)
|
||||||
|
.SetProperty(t => t.Result, (string?)null), ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test, verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
|
||||||
|
git commit -m "feat(data): add TaskRepository.ResetToManualAsync"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `TaskResetService` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/Services/TaskResetService.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs`
|
||||||
|
|
||||||
|
This service orchestrates Task 1 + Task 2, plus the "reject if Running" safety check and the SignalR broadcast.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the failing test — happy path**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Services;
|
||||||
|
|
||||||
|
public class TaskResetServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<GitRepoFixture> _fixtures = new();
|
||||||
|
private readonly List<DbFixture> _dbFixtures = new();
|
||||||
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetAsync_FailedTaskWithWorktree_ClearsEverything_AndPreservesRunHistory()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = new GitRepoFixture(); _fixtures.Add(repo);
|
||||||
|
var db = new DbFixture(); _dbFixtures.Add(db);
|
||||||
|
|
||||||
|
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
|
||||||
|
var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow };
|
||||||
|
|
||||||
|
using (var seed = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(seed).AddAsync(list);
|
||||||
|
await new TaskRepository(seed).AddAsync(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
// Seed a Failed task with a run row (we'll assert it's preserved).
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new TaskRepository(ctx).MarkFailedAsync(task.Id, DateTime.UtcNow, "it broke");
|
||||||
|
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
TaskId = task.Id,
|
||||||
|
RunNumber = 1,
|
||||||
|
IsRetry = false,
|
||||||
|
Prompt = "p",
|
||||||
|
SessionId = "s1",
|
||||||
|
FinishedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var broadcaster = new FakeHubBroadcaster();
|
||||||
|
var svc = new TaskResetService(db.CreateFactory(), wtMgr, broadcaster, NullLogger<TaskResetService>.Instance);
|
||||||
|
|
||||||
|
await svc.ResetAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
using var readCtx = db.CreateContext();
|
||||||
|
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Manual, after!.Status);
|
||||||
|
Assert.Null(after.Result);
|
||||||
|
Assert.Null(after.StartedAt);
|
||||||
|
Assert.Null(after.FinishedAt);
|
||||||
|
|
||||||
|
var wtAfter = await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Discarded, wtAfter!.State);
|
||||||
|
Assert.False(Directory.Exists(wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
var runs = await new TaskRunRepository(readCtx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Single(runs);
|
||||||
|
|
||||||
|
Assert.Contains(task.Id, broadcaster.TaskUpdatedIds);
|
||||||
|
Assert.Contains(task.Id, broadcaster.WorktreeUpdatedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
|
||||||
|
{
|
||||||
|
var db = new DbFixture(); _dbFixtures.Add(db);
|
||||||
|
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow };
|
||||||
|
var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow, Status = TaskStatus.Running, StartedAt = DateTime.UtcNow };
|
||||||
|
|
||||||
|
using (var seed = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(seed).AddAsync(list);
|
||||||
|
await new TaskRepository(seed).AddAsync(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
|
||||||
|
var svc = new TaskResetService(db.CreateFactory(), wtMgr, new FakeHubBroadcaster(), NullLogger<TaskResetService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.ResetAsync(task.Id, CancellationToken.None));
|
||||||
|
|
||||||
|
using var readCtx = db.CreateContext();
|
||||||
|
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Running, after!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var f in _fixtures) f.Dispose();
|
||||||
|
foreach (var d in _dbFixtures) d.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHubBroadcaster : HubBroadcaster
|
||||||
|
{
|
||||||
|
public List<string> TaskUpdatedIds { get; } = new();
|
||||||
|
public List<string> WorktreeUpdatedIds { get; } = new();
|
||||||
|
public FakeHubBroadcaster() : base(new FakeHubContext()) { }
|
||||||
|
public new Task TaskUpdated(string taskId) { TaskUpdatedIds.Add(taskId); return Task.CompletedTask; }
|
||||||
|
public new Task WorktreeUpdated(string taskId) { WorktreeUpdatedIds.Add(taskId); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check existing fakes: the test file assumes `FakeHubContext` exists under `ClaudeDo.Worker.Tests.Infrastructure` (the Worker.Tests CLAUDE.md lists `FakeHubContext`, `FakeHubClients`, `FakeClientProxy`). If `HubBroadcaster` methods are not virtual, the `new` keyword above will not intercept calls — instead, use the real `HubBroadcaster` with `FakeHubContext` and inspect the fake's recorded calls. Adjust the test implementation to use whichever approach matches the existing test conventions (see `QueueServiceTests` for precedent).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests, verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal`
|
||||||
|
Expected: FAIL — `TaskResetService` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `TaskResetService`**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Worker/Services/TaskResetService.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
public sealed class TaskResetService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly WorktreeManager _wtManager;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly ILogger<TaskResetService> _logger;
|
||||||
|
|
||||||
|
public TaskResetService(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
WorktreeManager wtManager,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
ILogger<TaskResetService> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_wtManager = wtManager;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResetAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
bool worktreeChanged = false;
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
var task = await taskRepo.GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
|
|
||||||
|
if (task.Status == TaskStatus.Running)
|
||||||
|
throw new InvalidOperationException("Cannot reset a running task. Cancel it first.");
|
||||||
|
|
||||||
|
var listRepo = new ListRepository(ctx);
|
||||||
|
var list = await listRepo.GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException("List not found.");
|
||||||
|
|
||||||
|
var wtRepo = new WorktreeRepository(ctx);
|
||||||
|
var wt = await wtRepo.GetByTaskIdAsync(taskId, ct);
|
||||||
|
|
||||||
|
if (wt is not null && wt.State == Data.Models.WorktreeState.Active && list.WorkingDir is not null)
|
||||||
|
{
|
||||||
|
// DiscardAsync uses its own DbContext internally; we close this one first.
|
||||||
|
ctx.Dispose();
|
||||||
|
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
|
||||||
|
worktreeChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
await taskRepo.ResetToManualAsync(taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
if (worktreeChanged)
|
||||||
|
await _broadcaster.WorktreeUpdated(taskId);
|
||||||
|
|
||||||
|
_logger.LogInformation("Reset task {TaskId} to Manual (worktree discarded: {Discarded})", taskId, worktreeChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the `ctx.Dispose()` inside a `using` block works because `Dispose` is idempotent. If you prefer, refactor to scope the first block with `{ }` + explicit `await using` and move the dispose before the call.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests, verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Services/TaskResetService.cs tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs
|
||||||
|
git commit -m "feat(worker): add TaskResetService for discard + reset flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Wire `TaskResetService` into DI and add `WorkerHub.ResetTask`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the service in DI**
|
||||||
|
|
||||||
|
Open `src/ClaudeDo.Worker/Program.cs`. Locate the block where `QueueService`, `WorktreeManager`, `HubBroadcaster`, `WorktreeMaintenanceService`, etc. are registered (look for `builder.Services.AddSingleton<QueueService>` or similar). Add next to them:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton<TaskResetService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Match the lifetime of sibling services (most are `AddSingleton`). If the sibling services use a different lifetime, match it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inject it into `WorkerHub`**
|
||||||
|
|
||||||
|
Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs`:
|
||||||
|
|
||||||
|
In the field block (near `_wtMaintenance`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly TaskResetService _resetService;
|
||||||
|
```
|
||||||
|
|
||||||
|
In the constructor signature, append `TaskResetService resetService` and assign it. The full updated constructor:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public WorkerHub(
|
||||||
|
QueueService queue,
|
||||||
|
AgentFileService agentService,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
|
TaskResetService resetService)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_agentService = agentService;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_wtMaintenance = wtMaintenance;
|
||||||
|
_resetService = resetService;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the `ResetTask` hub method**
|
||||||
|
|
||||||
|
Add inside `WorkerHub` (place it near `ContinueTask` for symmetry):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task ResetTask(string taskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _resetService.ResetAsync(taskId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
throw new HubException(ex.Message);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
throw new HubException("task not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the worker to verify wiring**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: SUCCESS (no compile errors). Note: `dotnet build ClaudeDo.slnx` requires .NET 9 — build individual csproj files instead.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full worker test suite**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests -v minimal`
|
||||||
|
Expected: PASS (all existing tests plus the new ones from Tasks 1-3).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||||||
|
git commit -m "feat(worker): expose ResetTask hub method"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add `ContinueTaskAsync` and `ResetTaskAsync` to `WorkerClient`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add both methods**
|
||||||
|
|
||||||
|
Open `src/ClaudeDo.Ui/Services/WorkerClient.cs`. Next to `RunNowAsync` (around line 166):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResetTaskAsync(string taskId)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("ResetTask", taskId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the existing `RunNowAsync` fires a local event first (e.g. `RunNowRequestedEvent?.Invoke(taskId)`), do **not** mirror that — Continue/Reset don't need UI-local optimistic state; we rely on `TaskUpdated` broadcasts.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the UI project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: SUCCESS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||||
|
git commit -m "feat(ui): add ContinueTaskAsync and ResetTaskAsync to WorkerClient"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Add commands and state to `DetailsIslandViewModel`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
|
||||||
|
Background you need before editing:
|
||||||
|
- The VM already has a `Task` property (`TaskRowViewModel?`) that represents the selected task.
|
||||||
|
- Status is tracked via `AgentStatusLabel` and exposed as `IsRunning`/`IsDone`/`IsFailed`.
|
||||||
|
- `TaskRowViewModel` may not currently hold the latest `SessionId`. You need a way to read the latest run's `SessionId` for the selected task — query `TaskRunRepository` during the existing task-load flow. If the VM already loads task runs (search for `TaskRunRepository` usage in `DetailsIslandViewModel`), piggyback on that; otherwise add a DB query inside the task-load method.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add observable properties for button visibility/enablement**
|
||||||
|
|
||||||
|
In the observable-property block (after `_promptInput`, around line 29), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
|
||||||
|
private bool _showFailedActions;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
|
private string? _latestRunSessionId;
|
||||||
|
```
|
||||||
|
|
||||||
|
Also hook `AgentStatusLabel` changes to refresh `ShowFailedActions`. Update the existing `OnAgentStatusLabelChanged` partial method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
partial void OnAgentStatusLabelChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
|
OnPropertyChanged(nameof(IsDone));
|
||||||
|
OnPropertyChanged(nameof(IsFailed));
|
||||||
|
ShowFailedActions = value == "Failed";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Populate `LatestRunSessionId` during task load**
|
||||||
|
|
||||||
|
Find the method in `DetailsIslandViewModel` that loads details for the selected task (likely named `LoadAsync`, `OnTaskChanged`, or similar — search for where `Turns`, `Tokens`, or `AgentStatusLabel` are assigned). Inside that method, after loading the task entity:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using var runCtx = _dbFactory.CreateDbContext();
|
||||||
|
var runRepo = new TaskRunRepository(runCtx);
|
||||||
|
var latestRun = await runRepo.GetLatestByTaskIdAsync(Task.Id);
|
||||||
|
LatestRunSessionId = latestRun?.SessionId;
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the method name `GetLatestByTaskIdAsync` exists on `TaskRunRepository` (it is used in `TaskRunner.ContinueAsync`). If the name differs, use whatever is exposed. Make sure this runs inside the same cancellation-safe block as the other loads — copy the existing pattern verbatim.
|
||||||
|
|
||||||
|
Also ensure `LatestRunSessionId` is reset to `null` when the selected task clears. If the VM has an `OnTaskChanged` partial method that clears other fields, add `LatestRunSessionId = null;` there too.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the two commands**
|
||||||
|
|
||||||
|
Add at the end of the class (next to `RunNowAsync` / `CanRunNow`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand(CanExecute = nameof(CanContinue))]
|
||||||
|
private async System.Threading.Tasks.Task ContinueAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanContinue() =>
|
||||||
|
Task != null
|
||||||
|
&& _worker.IsConnected
|
||||||
|
&& ShowFailedActions
|
||||||
|
&& !string.IsNullOrEmpty(LatestRunSessionId);
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanReset))]
|
||||||
|
private async System.Threading.Tasks.Task ResetAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
if (ConfirmAsync == null) return;
|
||||||
|
|
||||||
|
var confirmed = await ConfirmAsync(
|
||||||
|
$"Discard worktree and reset task?\nThis deletes branch claudedo/{Task.Id.Replace("-", "")} and all uncommitted changes.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await _worker.ResetTaskAsync(Task.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanReset() =>
|
||||||
|
Task != null
|
||||||
|
&& _worker.IsConnected
|
||||||
|
&& ShowFailedActions;
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the worker-connection PropertyChanged handler (around line 112) to notify the new commands:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_worker.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||||
|
{
|
||||||
|
RunNowCommand.NotifyCanExecuteChanged();
|
||||||
|
ContinueCommand.NotifyCanExecuteChanged();
|
||||||
|
ResetCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the UI project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: SUCCESS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
|
||||||
|
git commit -m "feat(ui): add Continue and Reset commands to DetailsIslandViewModel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Add the button row to `DetailsIslandView.axaml`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inspect the existing layout**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` end-to-end so you understand the grid layout. The `AgentStripView` sits at `Grid.Row="0"`. Decide whether to add a new grid row below it or to extend the agent strip itself. Simplest: add the button row to `AgentStripView.axaml`, since that control already contains `RunNowCommand` / `StopCommand` buttons and is bound to the same VM.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the buttons to `AgentStripView.axaml`**
|
||||||
|
|
||||||
|
Open `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`. Locate the existing `RunNowCommand` button (around line 49). After it, add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button
|
||||||
|
Content="Continue"
|
||||||
|
Command="{Binding ContinueCommand}"
|
||||||
|
IsVisible="{Binding ShowFailedActions}"
|
||||||
|
ToolTip.Tip="Resume the failed Claude session with 'Continue working on this task.'"
|
||||||
|
Margin="4,0,0,0"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
Content="Reset"
|
||||||
|
Command="{Binding ResetCommand}"
|
||||||
|
IsVisible="{Binding ShowFailedActions}"
|
||||||
|
ToolTip.Tip="Discard the worktree and return the task to Manual"
|
||||||
|
Margin="4,0,0,0"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Match the style (classes, padding, height) of the surrounding `RunNow` / `Stop` buttons — copy their `Classes`, `Padding`, and `Height` attributes verbatim so the row stays visually consistent.
|
||||||
|
|
||||||
|
For the Continue button's disabled-with-tooltip affordance when there's no session_id: the `CanExecute` binding already disables the button; Avalonia shows tooltips on disabled controls when `ToolTip.ShowOnDisabled="True"` — set that on the Continue button and add a second tooltip hinting at the reason is unnecessary since the button will simply be greyed out. If you want an explicit "No session to resume" hint, add a `Classes.disabled` trigger or use a `MultiBinding`; skip this refinement unless it is trivial in the existing theme.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the confirmation dialog**
|
||||||
|
|
||||||
|
The VM's `ResetAsync` uses `ConfirmAsync` (a `Func<string, Task<bool>>` already declared on the VM at line 100). Search the codebase for where `ConfirmAsync` is assigned on the `DetailsIslandViewModel` instance — there is an existing assignment because `DeleteTaskCommand` already uses it. No new wiring needed; the same dialog will handle Reset confirmations.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Launch the UI and smoke-test**
|
||||||
|
|
||||||
|
1. In one terminal: `dotnet run --project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
2. In another terminal: `dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||||
|
3. Create a task that will fail (e.g. a task pointing at a non-existent working dir, or type something into Claude's prompt that makes it error). Wait for status `Failed`.
|
||||||
|
4. Verify the Continue and Reset buttons appear in the details pane.
|
||||||
|
5. Click Reset → confirm → verify the task row flips to `Manual`, the worktree directory is gone from disk, and the branch is gone from `git branch --list | grep claudedo/` in the target repo.
|
||||||
|
6. Create another failing task. Click Continue → verify a new run starts (status flips to `Running`), resumes the same Claude session, and completes (Done or Failed again).
|
||||||
|
7. Verify on a task that has no session_id (e.g. cancel before Claude emits anything), the Continue button is disabled.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml
|
||||||
|
git commit -m "feat(ui): add Continue and Reset buttons to agent strip"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Update project docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update Worker CLAUDE.md**
|
||||||
|
|
||||||
|
Under the `SignalR Hub` section, extend the `WorkerHub methods` line:
|
||||||
|
|
||||||
|
```
|
||||||
|
**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `ResetTask(taskId)`, `GetAgents()`, `RefreshAgents()`
|
||||||
|
```
|
||||||
|
|
||||||
|
Under `Key Components`, add one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update UI CLAUDE.md**
|
||||||
|
|
||||||
|
Extend the `WorkerClient` description to mention the two new methods:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **WorkerClient** — ... Methods: StartAsync, RunNowAsync, CancelTaskAsync, ContinueTaskAsync, ResetTaskAsync, WakeQueueAsync. Events: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
|
||||||
|
git commit -m "docs: note ResetTask hub method and TaskResetService"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Continue button (canned prompt, one-click, disabled without session) → Task 6 (`ContinueCommand`, `CanContinue`) + Task 7 (button).
|
||||||
|
- Reset button (always enabled on Failed, confirm dialog) → Task 6 (`ResetCommand`, `CanReset`) + Task 7 (button + confirm).
|
||||||
|
- Buttons only on Failed → `ShowFailedActions` drives `IsVisible` (Task 6, Task 7).
|
||||||
|
- Hub `ResetTask` → Task 4.
|
||||||
|
- `WorktreeManager.DiscardAsync` → Task 1.
|
||||||
|
- `TaskRepository.ResetToManualAsync` → Task 2.
|
||||||
|
- Reject reset on Running → Task 3.
|
||||||
|
- Worktree-remove failure leaves task Failed → Task 3 (`DiscardAsync` throws before `ResetToManualAsync` is called).
|
||||||
|
- Run history preserved → Task 2 and Task 3 assertions.
|
||||||
|
- Tests — WorktreeManager.DiscardAsync, TaskRepository.ResetToManualAsync, TaskResetService full flow, reject running → Tasks 1, 2, 3.
|
||||||
|
- Test for "ResetTask rejects running" → Task 3 test 2.
|
||||||
|
- Test for "worktree remove failure leaves task Failed" → covered implicitly by the code structure (Task 3 does not call `ResetToManualAsync` if `DiscardAsync` throws). If you want an explicit test, add one in Task 3 by injecting a failure; marking optional as the control flow is straightforward.
|
||||||
|
|
||||||
|
**Placeholder scan:** no TBDs; every code step has code; commands include expected output.
|
||||||
|
|
||||||
|
**Type consistency:** `DiscardAsync(WorktreeEntity wt, string workingDir, ct)` used consistently (Tasks 1 and 3). `ResetToManualAsync(taskId, ct)` used consistently (Tasks 2 and 3). `ContinueTaskAsync`/`ResetTaskAsync` on `WorkerClient` match the hub method names. `ShowFailedActions`, `LatestRunSessionId`, `CanContinue`, `CanReset` referenced consistently across Task 6 and Task 7.
|
||||||
189
docs/superpowers/plans/2026-04-21-open-items-consolidation.md
Normal file
189
docs/superpowers/plans/2026-04-21-open-items-consolidation.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Open Items Consolidation — 2026-04-21
|
||||||
|
|
||||||
|
Consolidates everything still open from `docs/open.md`, `docs/improvement-plan.md`, and the 2026-04-16 subtask-tree spec. Today's plans/specs (stream-formatter, settings-modal, continue-and-reset) are explicitly out of scope — those are tracked separately.
|
||||||
|
|
||||||
|
Grouped by priority and sorted by UX impact vs. effort. Each item lists **Soll**, **Dateien**, **Aufwand**, **Risiko**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — UX blockers and robustness
|
||||||
|
|
||||||
|
### 1. Auto-Reconnect (ex IP-1)
|
||||||
|
**Soll:** `HubConnectionBuilder.WithAutomaticReconnect(...)` + event handlers for `Reconnecting`/`Reconnected`/`Closed`. Exponential backoff.
|
||||||
|
**Dateien:** `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
**Aufwand:** klein (~30 Zeilen)
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 2. Reconnect-State in StatusBar (ex IP-7)
|
||||||
|
**Soll:** States `connected | connecting | reconnecting | offline`, farb-codiert. Depends on #1.
|
||||||
|
**Dateien:** `src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs`, StatusBar view
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 3. Folder-Picker für Working Directory (ex open.md 2.1)
|
||||||
|
**Soll:** Button neben Pfad-TextBox → `IStorageProvider.OpenFolderPickerAsync`.
|
||||||
|
**Dateien:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml(.cs)`, `ViewModels/ListEditorViewModel.cs`
|
||||||
|
**Aufwand:** klein (~30 Zeilen)
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 4. Markdown-Rendering für Result/Description (ex open.md 2.3)
|
||||||
|
**Soll:** `Markdown.Avalonia` Paket einbinden, `MarkdownScrollViewer` statt readonly `TextBox` in Details-Island.
|
||||||
|
**Dateien:** `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`, `Views/Islands/DetailsIslandView.axaml`
|
||||||
|
**Aufwand:** mittel (Theme-Integration kann zicken)
|
||||||
|
**Risiko:** klein–mittel
|
||||||
|
|
||||||
|
### 5. Live-Log Auto-Scroll (ex open.md 2.4)
|
||||||
|
**Soll:** Sticky-Bottom-Pattern: `ScrollToEnd()` auf neue Zeilen, außer User hat manuell hochgescrollt.
|
||||||
|
**Dateien:** `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml(.cs)`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 6. CLI-Preflight beim Worker-Start (ex open.md 3.1)
|
||||||
|
**Soll:** Startup-Check `claude --version` + Login-Status. Wenn fehlt → laut failen mit Hinweis, nicht still in Queue idlen.
|
||||||
|
**Dateien:** `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs`, Worker startup (Program.cs / HostedService)
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — Daten & Features
|
||||||
|
|
||||||
|
### 7. Notes-Mode (ex IP-2)
|
||||||
|
**Soll:** Neue Spalte `lists.kind` (`agent` | `notes`). Worker filtert auf `kind = 'agent'`. UI versteckt Run/Schedule/Worktree-Felder für `notes`.
|
||||||
|
**Dateien:**
|
||||||
|
- Migration + `TaskList` Entity + `IEntityTypeConfiguration`
|
||||||
|
- `src/ClaudeDo.Worker/Queue/QueueService.cs` (Filter)
|
||||||
|
- `ViewModels/ListEditorViewModel.cs`, `Views/Islands/*` (conditional visibility)
|
||||||
|
**Aufwand:** mittel
|
||||||
|
**Risiko:** mittel (Default für bestehende Listen = `agent`)
|
||||||
|
|
||||||
|
### 8. Subtask-Tree im TaskList-Island (ex 2026-04-16 spec)
|
||||||
|
**Soll:** Indented Subtasks unter Parent-Task, Expand/Collapse, Chevron-Spalte, Count-Indikator. Batch-Query `GetCountsByTaskIdsAsync`.
|
||||||
|
**Dateien:** `ViewModels/TaskItemViewModel.cs` (+ `Subtasks`, `IsExpanded`, `HasSubtasks`), `Views/Islands/TaskListView.axaml`, `Data/Repositories/SubtaskRepository.cs`
|
||||||
|
**Aufwand:** mittel
|
||||||
|
**Risiko:** klein–mittel (spec ist fertig)
|
||||||
|
|
||||||
|
### 9. Tag-Repository `GetAllKnownTagsAsync` (ex IP-8)
|
||||||
|
**Soll:** Distinct-Query über alle Tags. Voraussetzung für #10.
|
||||||
|
**Dateien:** neuer `Data/Repositories/TagRepository.cs` (oder in bestehendem Repo)
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 10. Tag Multi-Select Control (ex IP-4)
|
||||||
|
**Soll:** AutoCompleteBox / Chips statt Freitext. Datenquelle aus #9.
|
||||||
|
**Dateien:** `Views/Islands/DetailsIslandView.axaml` (Tag-Sektion), ggf. neues `TagPickerControl`
|
||||||
|
**Aufwand:** klein–mittel
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 11. Worktree-Cleanup bei Anlegefehler (ex open.md 3.2)
|
||||||
|
**Soll:** Wenn `git worktree add` teilweise anlegt dann failed → best-effort `git worktree remove --force` + DB-Row nicht persistieren.
|
||||||
|
**Dateien:** `src/ClaudeDo.Data/Services/GitService.cs`, `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 12. Tag-Negation / Exclusion (ex open.md 3.4)
|
||||||
|
**Soll:** Queue respektiert `task_tag_exclusions` laut Plan. Aktuell nur `agent`-Include.
|
||||||
|
**Dateien:** `src/ClaudeDo.Worker/Queue/QueueService.cs`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3 — Tests, CI, Docs
|
||||||
|
|
||||||
|
### 13. Gitea-Actions CI Pipeline (ex open.md 5.1)
|
||||||
|
**Soll:** `.gitea/workflows/ci.yml`: restore → build → test auf push/PR. Nur `release.yml` existiert bisher.
|
||||||
|
**Dateien:** neu — `.gitea/workflows/ci.yml`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 14. SignalR Roundtrip-Test (ex open.md 5.2)
|
||||||
|
**Soll:** `WebApplicationFactory` + `HubConnectionBuilder` testen `Ping`, `GetActive`, `RunNow`-Throw-Verhalten.
|
||||||
|
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
|
||||||
|
**Aufwand:** mittel
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 15. Claude-CLI Smoke-Test (ex open.md 5.3)
|
||||||
|
**Soll:** `[Fact(Skip=...)]` Real-CLI-Test, aktiviert nur wenn `CLAUDE_AUTHENTICATED=1`.
|
||||||
|
**Dateien:** neu — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
### 16. README ausbauen (ex open.md 6.1)
|
||||||
|
**Soll:** Ist 107 Zeilen. Ergänzen: Screenshots, Quickstart (Worker + UI starten), Konfiguration, Troubleshooting.
|
||||||
|
**Dateien:** `README.md`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** keiner
|
||||||
|
|
||||||
|
### 17. `docs/architecture.md` herausziehen (ex open.md 6.2)
|
||||||
|
**Soll:** Architektur-Sektion aus `plan.md` in eigenes Dokument.
|
||||||
|
**Dateien:** neu — `docs/architecture.md`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** keiner
|
||||||
|
|
||||||
|
### 18. ADRs für Kern-Entscheidungen (ex open.md 6.3)
|
||||||
|
**Soll:** Kurze ADRs (1 Seite) für: SignalR vs. SQLite-Polling; Worktree pro Task; SignalR über Loopback ohne Auth; EF Core statt Dapper.
|
||||||
|
**Dateien:** neu — `docs/adr/0001-*.md` … `0004-*.md`
|
||||||
|
**Aufwand:** klein
|
||||||
|
**Risiko:** keiner
|
||||||
|
|
||||||
|
### 19. Strukturiertes Logging (ex open.md 3.3)
|
||||||
|
**Soll:** `Console.WriteLine` / manuelle Log-Zeilen durch `ILogger<T>` ersetzen. Log-Levels, Scope für TaskId.
|
||||||
|
**Dateien:** `src/ClaudeDo.Worker/**` (query-able via `grep Console.Write`)
|
||||||
|
**Aufwand:** mittel
|
||||||
|
**Risiko:** klein
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P4 — Service-Deployment (später)
|
||||||
|
|
||||||
|
### 20. Windows-Service Hosting in Code (ex open.md 4.1)
|
||||||
|
**Soll:** `.UseWindowsService()` + `Microsoft.Extensions.Hosting.WindowsServices` Paket.
|
||||||
|
**Aufwand:** klein
|
||||||
|
|
||||||
|
### 21. Absolute Pfad-Auflösung (ex open.md 4.2)
|
||||||
|
**Soll:** Config-Pfade immer absolut auflösen (Service läuft in `C:\Windows\System32`).
|
||||||
|
**Aufwand:** klein
|
||||||
|
|
||||||
|
### 22. Install-Skripte / Doku (ex open.md 4.3)
|
||||||
|
**Soll:** `sc create` / PowerShell-Skript, Doku in README.
|
||||||
|
**Aufwand:** klein
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Reihenfolge
|
||||||
|
|
||||||
|
**Block 1 — Sofortige UX-Wins (1 Session):**
|
||||||
|
1. → 2. (Auto-Reconnect + StatusBar) — zusammenhängend, klein
|
||||||
|
3. Folder-Picker
|
||||||
|
5. Log Auto-Scroll
|
||||||
|
|
||||||
|
**Block 2 — Content-Qualität (1 Session):**
|
||||||
|
4. Markdown-Rendering
|
||||||
|
6. CLI-Preflight
|
||||||
|
|
||||||
|
**Block 3 — Daten-Features (2 Sessions):**
|
||||||
|
7. Notes-Mode (mit Migration)
|
||||||
|
8. Subtask-Tree
|
||||||
|
9. → 10. Tag-Repo + Multi-Select
|
||||||
|
|
||||||
|
**Block 4 — Worker-Robustheit:**
|
||||||
|
11. Worktree-Cleanup
|
||||||
|
12. Tag-Exclusion
|
||||||
|
19. Strukturiertes Logging
|
||||||
|
|
||||||
|
**Block 5 — Tests + CI + Docs:**
|
||||||
|
13. CI Pipeline
|
||||||
|
14. → 15. Hub-Tests + Smoke-Test
|
||||||
|
16. → 17. → 18. README + architecture.md + ADRs
|
||||||
|
|
||||||
|
**Block 6 — Service-Deployment (wenn gewünscht):**
|
||||||
|
20. → 21. → 22.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht im Plan
|
||||||
|
|
||||||
|
- Alles aus heute (2026-04-21): Stream-Formatter-Rewrite, Settings-Modal, Continue-and-Reset — sind eigene Plans.
|
||||||
|
- UI-Rewrite-Islands, UI-Polish-Design-Parity, UX-Redesign, Worker-CLI-Modernization, EF-Core-Migration, Installer-Download-Mode, Logic-Bug-Fixes — bereits gemerged (siehe git log).
|
||||||
|
- IP-3 (Doppelklick) und IP-5 (Kontextmenü) — vermutlich im Zuge des UI-Rewrites erledigt; falls nicht, trivial nachzuziehen.
|
||||||
614
docs/superpowers/plans/2026-04-21-stream-formatter-rewrite.md
Normal file
614
docs/superpowers/plans/2026-04-21-stream-formatter-rewrite.md
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
# Stream Formatter Rewrite — 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:** Rewrite `StreamLineFormatter` so Claude CLI stream-json messages (system/init, assistant text, assistant tool_use, user tool_result, result) render as compact readable lines in the Details pane.
|
||||||
|
|
||||||
|
**Architecture:** Single-file rewrite of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`. Public API (`FormatLine(string)` / `FormatFile(string)` / `Trim`) and constants unchanged. Internal dispatch switches on top-level `type`; per-type helpers return one or more `\n`-terminated display lines, concatenated into the return string.
|
||||||
|
|
||||||
|
**Tech Stack:** C# 12, .NET 8, `System.Text.Json` (already in use).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-21-stream-formatter-rewrite-design.md`
|
||||||
|
|
||||||
|
**Testing:** Skipped per user decision; verification is a manual build after each task and a final end-to-end run of a real task.
|
||||||
|
|
||||||
|
**Build command (repo uses csproj builds, not slnx, on .NET 8):**
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` — complete rewrite of parsing logic; keeps public class surface.
|
||||||
|
|
||||||
|
No other files change. `DetailsIslandViewModel` and the Worker pipeline are unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Replace the dispatch skeleton
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||||
|
|
||||||
|
Swap the old top-level `switch` for one that names every supported message type. Every branch returns `null` for now except `result` and `api_retry`, which keep their existing behavior. This gives us a clean compile before we fill in each branch.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Overwrite the file with the new skeleton**
|
||||||
|
|
||||||
|
Replace the entire contents of `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs` with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Helpers;
|
||||||
|
|
||||||
|
public class StreamLineFormatter
|
||||||
|
{
|
||||||
|
private const int MaxLength = 50_000;
|
||||||
|
private const int MaxArgChars = 120;
|
||||||
|
|
||||||
|
public string? FormatLine(string line)
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc = JsonDocument.Parse(line);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (doc)
|
||||||
|
{
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
return null;
|
||||||
|
if (!root.TryGetProperty("type", out var typeProp))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return typeProp.GetString() switch
|
||||||
|
{
|
||||||
|
"system" => FormatSystem(root),
|
||||||
|
"assistant" => FormatAssistant(root),
|
||||||
|
"user" => FormatUser(root),
|
||||||
|
"result" => FormatResult(root),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatSystem(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("subtype", out var subtypeProp))
|
||||||
|
return null;
|
||||||
|
return subtypeProp.GetString() switch
|
||||||
|
{
|
||||||
|
"api_retry" => "[Retrying API call...]\n",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatAssistant(JsonElement root) => null;
|
||||||
|
|
||||||
|
private static string? FormatUser(JsonElement root) => null;
|
||||||
|
|
||||||
|
private static string? FormatResult(JsonElement root)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("result", out var resultProp))
|
||||||
|
return $"\n--- Result ---\n{resultProp.GetString()}\n";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FormatFile(string filePath)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var line in File.ReadLines(filePath))
|
||||||
|
{
|
||||||
|
var formatted = FormatLine(line);
|
||||||
|
if (formatted is not null)
|
||||||
|
sb.Append(formatted);
|
||||||
|
}
|
||||||
|
return Trim(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Trim(string text)
|
||||||
|
{
|
||||||
|
if (text.Length <= MaxLength) return text;
|
||||||
|
var trimStart = text.Length - MaxLength;
|
||||||
|
var newlineAfter = text.IndexOf('\n', trimStart);
|
||||||
|
if (newlineAfter >= 0 && newlineAfter < trimStart + 200)
|
||||||
|
trimStart = newlineAfter + 1;
|
||||||
|
return text[trimStart..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||||
|
git commit -m "refactor(ui): skeleton dispatch for StreamLineFormatter rewrite"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add system/init formatting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||||
|
|
||||||
|
Emit `[session <id8> · <model>]` when the CLI announces the session at startup.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the `FormatSystem` method**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string? FormatSystem(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("subtype", out var subtypeProp))
|
||||||
|
return null;
|
||||||
|
return subtypeProp.GetString() switch
|
||||||
|
{
|
||||||
|
"api_retry" => "[Retrying API call...]\n",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string? FormatSystem(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("subtype", out var subtypeProp))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var subtype = subtypeProp.GetString();
|
||||||
|
switch (subtype)
|
||||||
|
{
|
||||||
|
case "api_retry":
|
||||||
|
return "[Retrying API call...]\n";
|
||||||
|
|
||||||
|
case "init":
|
||||||
|
{
|
||||||
|
var sessionId = root.TryGetProperty("session_id", out var sid)
|
||||||
|
? sid.GetString() : null;
|
||||||
|
var model = root.TryGetProperty("model", out var m)
|
||||||
|
? m.GetString() : null;
|
||||||
|
|
||||||
|
var shortId = sessionId is { Length: >= 8 }
|
||||||
|
? sessionId[..8]
|
||||||
|
: sessionId ?? "?";
|
||||||
|
var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}";
|
||||||
|
return $"[session {shortId}{modelPart}]\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||||
|
git commit -m "feat(ui): format system init message in StreamLineFormatter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add assistant text + thinking filter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||||
|
|
||||||
|
Iterate `message.content[]`. Emit each `text` block verbatim with a trailing `\n`; skip `thinking`. Leave `tool_use` for the next task (still returns nothing for now).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the `FormatAssistant` method**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string? FormatAssistant(JsonElement root) => null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string? FormatAssistant(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!TryGetContentArray(root, out var content))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var block in content.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (block.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
|
||||||
|
|
||||||
|
switch (blockTypeProp.GetString())
|
||||||
|
{
|
||||||
|
case "text":
|
||||||
|
if (block.TryGetProperty("text", out var textProp))
|
||||||
|
{
|
||||||
|
var text = textProp.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
sb.Append(text);
|
||||||
|
if (!text.EndsWith('\n')) sb.Append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tool_use":
|
||||||
|
// Filled in by a later task.
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "thinking":
|
||||||
|
default:
|
||||||
|
// Filtered.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.Length == 0 ? null : sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetContentArray(JsonElement root, out JsonElement content)
|
||||||
|
{
|
||||||
|
content = default;
|
||||||
|
if (!root.TryGetProperty("message", out var message)) return false;
|
||||||
|
if (message.ValueKind != JsonValueKind.Object) return false;
|
||||||
|
if (!message.TryGetProperty("content", out var c)) return false;
|
||||||
|
if (c.ValueKind != JsonValueKind.Array) return false;
|
||||||
|
content = c;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||||
|
git commit -m "feat(ui): render assistant text blocks, skip thinking"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add tool_use block formatting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||||
|
|
||||||
|
Fill in the `tool_use` case inside `FormatAssistant`. Per-tool label/arg logic lives in a dedicated helper.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the `tool_use` case body**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case "tool_use":
|
||||||
|
// Filled in by a later task.
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case "tool_use":
|
||||||
|
sb.Append(FormatToolUse(block));
|
||||||
|
sb.Append('\n');
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add helper methods at the end of the class (before `FormatFile`)**
|
||||||
|
|
||||||
|
Insert just above the `public string FormatFile(string filePath)` method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string FormatToolUse(JsonElement block)
|
||||||
|
{
|
||||||
|
var name = block.TryGetProperty("name", out var nameProp)
|
||||||
|
? nameProp.GetString() ?? "?"
|
||||||
|
: "?";
|
||||||
|
|
||||||
|
JsonElement input = default;
|
||||||
|
var hasInput = block.TryGetProperty("input", out input)
|
||||||
|
&& input.ValueKind == JsonValueKind.Object;
|
||||||
|
|
||||||
|
var label = name;
|
||||||
|
if (hasInput && (name == "Task" || name == "Agent"))
|
||||||
|
{
|
||||||
|
var sub = GetStr(input, "subagent_type");
|
||||||
|
if (!string.IsNullOrEmpty(sub))
|
||||||
|
label = $"{name}: {sub}";
|
||||||
|
}
|
||||||
|
|
||||||
|
string? arg = hasInput ? BuildToolArg(name, input) : null;
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(arg)
|
||||||
|
? $"[{label}]"
|
||||||
|
: $"[{label}] {arg}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BuildToolArg(string toolName, JsonElement input)
|
||||||
|
{
|
||||||
|
switch (toolName)
|
||||||
|
{
|
||||||
|
case "Read":
|
||||||
|
case "Write":
|
||||||
|
case "Edit":
|
||||||
|
case "NotebookEdit":
|
||||||
|
return Basename(GetStr(input, "file_path"));
|
||||||
|
|
||||||
|
case "Bash":
|
||||||
|
case "PowerShell":
|
||||||
|
{
|
||||||
|
var cmd = GetStr(input, "command");
|
||||||
|
return string.IsNullOrEmpty(cmd) ? null : "$ " + Truncate(cmd, MaxArgChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Grep":
|
||||||
|
{
|
||||||
|
var p = GetStr(input, "pattern");
|
||||||
|
return string.IsNullOrEmpty(p) ? null : $"\"{Truncate(p, MaxArgChars)}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Glob":
|
||||||
|
return Truncate(GetStr(input, "pattern"), MaxArgChars);
|
||||||
|
|
||||||
|
case "Task":
|
||||||
|
case "Agent":
|
||||||
|
return Truncate(GetStr(input, "description"), MaxArgChars);
|
||||||
|
|
||||||
|
case "WebFetch":
|
||||||
|
return GetStr(input, "url");
|
||||||
|
|
||||||
|
case "WebSearch":
|
||||||
|
{
|
||||||
|
var q = GetStr(input, "query");
|
||||||
|
return string.IsNullOrEmpty(q) ? null : $"\"{Truncate(q, MaxArgChars)}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
case "TodoWrite":
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetStr(JsonElement obj, string name)
|
||||||
|
=> obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String
|
||||||
|
? p.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string Basename(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return "";
|
||||||
|
var i = path.LastIndexOfAny(new[] { '/', '\\' });
|
||||||
|
return i < 0 ? path : path[(i + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string? s, int max)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return "";
|
||||||
|
return s.Length <= max ? s : s[..max] + "…";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||||
|
git commit -m "feat(ui): render assistant tool_use blocks with per-tool args"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add user tool_result formatting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||||
|
|
||||||
|
Iterate `message.content[]` for `tool_result` blocks and emit `→ <summary>` lines per the spec rules.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the `FormatUser` method**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string? FormatUser(JsonElement root) => null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string? FormatUser(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!TryGetContentArray(root, out var content))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var block in content.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (block.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
|
||||||
|
if (blockTypeProp.GetString() != "tool_result") continue;
|
||||||
|
|
||||||
|
var summary = BuildToolResultSummary(root, block);
|
||||||
|
if (!string.IsNullOrEmpty(summary))
|
||||||
|
{
|
||||||
|
sb.Append("→ ");
|
||||||
|
sb.Append(summary);
|
||||||
|
sb.Append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.Length == 0 ? null : sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildToolResultSummary(JsonElement root, JsonElement block)
|
||||||
|
{
|
||||||
|
var isError = block.TryGetProperty("is_error", out var errProp)
|
||||||
|
&& errProp.ValueKind == JsonValueKind.True;
|
||||||
|
|
||||||
|
var contentText = ResolveContentText(block);
|
||||||
|
|
||||||
|
if (isError)
|
||||||
|
{
|
||||||
|
var msg = FirstNonEmptyLine(contentText);
|
||||||
|
return string.IsNullOrEmpty(msg) ? "error" : $"error: {Truncate(msg, MaxArgChars)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// tool_use_result.file.numLines shortcut for Read-style results
|
||||||
|
if (root.TryGetProperty("tool_use_result", out var tur)
|
||||||
|
&& tur.ValueKind == JsonValueKind.Object
|
||||||
|
&& tur.TryGetProperty("file", out var file)
|
||||||
|
&& file.ValueKind == JsonValueKind.Object
|
||||||
|
&& file.TryGetProperty("numLines", out var nl)
|
||||||
|
&& nl.ValueKind == JsonValueKind.Number
|
||||||
|
&& nl.TryGetInt32(out var lines))
|
||||||
|
{
|
||||||
|
return $"{lines} lines";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(contentText))
|
||||||
|
return "ok";
|
||||||
|
|
||||||
|
var first = FirstNonEmptyLine(contentText);
|
||||||
|
return Truncate(first, MaxArgChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveContentText(JsonElement block)
|
||||||
|
{
|
||||||
|
if (!block.TryGetProperty("content", out var c))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
if (c.ValueKind == JsonValueKind.String)
|
||||||
|
return c.GetString() ?? "";
|
||||||
|
|
||||||
|
if (c.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var part in c.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (part.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
if (!part.TryGetProperty("type", out var pt)) continue;
|
||||||
|
if (pt.GetString() != "text") continue;
|
||||||
|
if (part.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
if (sb.Length > 0) sb.Append('\n');
|
||||||
|
sb.Append(t.GetString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstNonEmptyLine(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return "";
|
||||||
|
foreach (var raw in s.Split('\n'))
|
||||||
|
{
|
||||||
|
var line = raw.TrimEnd('\r').Trim();
|
||||||
|
if (line.Length > 0) return line;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
|
||||||
|
git commit -m "feat(ui): render user tool_result blocks as one-line summaries"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Manual end-to-end verification
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build everything the app needs**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` and `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: both succeed, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Start the Worker in one terminal**
|
||||||
|
|
||||||
|
Run: `dotnet run --project src/ClaudeDo.Worker`
|
||||||
|
Expected: SignalR hub bound to `127.0.0.1:47821`, no crash.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Start the App in another terminal**
|
||||||
|
|
||||||
|
Run: `dotnet run --project src/ClaudeDo.App`
|
||||||
|
Expected: UI opens, status bar shows online.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run any task tagged "agent" (e.g. "create a README")**
|
||||||
|
|
||||||
|
In the Details pane, verify the log shows:
|
||||||
|
- A `[session <id>…]` line at the top
|
||||||
|
- Plain prose lines for assistant text
|
||||||
|
- `[Read] <file>`, `[Bash] $ …`, `[Write] <file>` etc. for tool calls
|
||||||
|
- `→ <N> lines` / `→ ok` / `→ error: …` lines after each tool call
|
||||||
|
- A final `--- Result ---` block
|
||||||
|
- **No raw JSON anywhere**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Spot-check the raw log file**
|
||||||
|
|
||||||
|
Open `~/.todo-app/logs/<task>.log` (or equivalent) and confirm the full JSON is still there for debugging — the formatter must not have altered persisted logs.
|
||||||
|
|
||||||
|
- [ ] **Step 6: If any issues surface, fix inline and re-verify**
|
||||||
|
|
||||||
|
Common gotchas to check for if you see blank lines or missing output:
|
||||||
|
- `message.content` sometimes absent → already guarded by `TryGetContentArray`
|
||||||
|
- Unknown tool name → should render `[<name>]` with no arg
|
||||||
|
- `tool_result.content` array form → covered by `ResolveContentText`
|
||||||
|
|
||||||
|
No further commit unless a fix was needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Implementation Self-Review
|
||||||
|
|
||||||
|
After the tasks above are done, verify:
|
||||||
|
|
||||||
|
1. Every message type listed in the spec's "Output format" table is implemented (`system/init`, `system/api_retry`, `system/other`, `assistant text`, `assistant tool_use`, `assistant thinking`, `user tool_result`, `result`, parse failure).
|
||||||
|
2. No `TODO` / `TBD` / commented-out stubs remain in `StreamLineFormatter.cs`.
|
||||||
|
3. Tool labels match the spec table exactly (`[Read]`, `[Bash] $ …`, `[Task: <sub>] <desc>`, etc.).
|
||||||
|
4. Public API surface (`FormatLine`, `FormatFile`, `Trim`, `MaxLength` behavior) is unchanged.
|
||||||
|
5. No edits outside `StreamLineFormatter.cs` (per the spec's non-goals).
|
||||||
1223
docs/superpowers/plans/2026-04-22-agent-settings-ui.md
Normal file
1223
docs/superpowers/plans/2026-04-22-agent-settings-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
1955
docs/superpowers/plans/2026-04-22-worktree-merge.md
Normal file
1955
docs/superpowers/plans/2026-04-22-worktree-merge.md
Normal file
File diff suppressed because it is too large
Load Diff
852
docs/superpowers/plans/2026-04-23-default-agents.md
Normal file
852
docs/superpowers/plans/2026-04-23-default-agents.md
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
# Default Agents 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:** Ship ClaudeDo with 6 default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher) that seed into `~/.todo-app/agents/` on first launch, with a "Restore defaults" button in the settings modal.
|
||||||
|
|
||||||
|
**Architecture:** Bundled `.md` files in `src/ClaudeDo.Worker/DefaultAgents/` are copied to the Worker output folder. A new `DefaultAgentSeeder` service copies any missing file into the user's agents dir — run once at startup, and again on demand via a new `WorkerHub.RestoreDefaultAgents` method invoked by a button in `SettingsModalView`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8 / ASP.NET Core / SignalR / Avalonia 12 / CommunityToolkit.Mvvm / xUnit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
|
||||||
|
- `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add Content item group for `DefaultAgents\*.md`
|
||||||
|
- `src/ClaudeDo.Worker/Program.cs` — register seeder, run `SeedMissingAsync()` once at startup
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — inject `DefaultAgentSeeder`, add `RestoreDefaultAgents` method
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `RestoreDefaultAgentsAsync` method + `SeedResultDto` record
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — add `RestoreDefaultAgentsCommand`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — add button section
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs` — add seeder integration test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Bundle default agent markdown files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `code-reviewer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: code-reviewer
|
||||||
|
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Logic errors, off-by-one bugs, null/empty handling
|
||||||
|
- Broken invariants, race conditions, resource leaks
|
||||||
|
- Violations of the project's established conventions (read nearby code first)
|
||||||
|
- Missing error handling at system boundaries (external input, IO, network)
|
||||||
|
|
||||||
|
Skip:
|
||||||
|
- Style preferences the codebase doesn't enforce
|
||||||
|
- Speculative "what if" concerns
|
||||||
|
- Renaming for its own sake
|
||||||
|
|
||||||
|
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `test-writer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: test-writer
|
||||||
|
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test-writer. Your job is to write focused, useful tests for code under review.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Read the target code and identify the observable behavior.
|
||||||
|
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
|
||||||
|
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- One behavior per test. Clear Arrange/Act/Assert.
|
||||||
|
- No tests for private implementation details — exercise public API.
|
||||||
|
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
|
||||||
|
- Skip trivially-correct tests (getter returns what you set).
|
||||||
|
|
||||||
|
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `debugger.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: debugger
|
||||||
|
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a debugger. You do NOT guess at fixes — you find the root cause first.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
|
||||||
|
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
|
||||||
|
3. Hypothesize. State a specific, falsifiable cause.
|
||||||
|
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
|
||||||
|
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
|
||||||
|
|
||||||
|
Anti-patterns to avoid:
|
||||||
|
- Making changes to "see if it works"
|
||||||
|
- Adding try/catch to silence errors
|
||||||
|
- Declaring the bug fixed without reproducing the fix
|
||||||
|
|
||||||
|
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write `security-reviewer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: security-reviewer
|
||||||
|
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
|
||||||
|
|
||||||
|
Check for:
|
||||||
|
- Injection: SQL, command, path traversal, XSS, template injection
|
||||||
|
- Auth: missing authorization, token handling, session fixation
|
||||||
|
- Input validation at system boundaries (HTTP, files, IPC)
|
||||||
|
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
|
||||||
|
- Unsafe deserialization, XXE, SSRF
|
||||||
|
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
|
||||||
|
|
||||||
|
Ignore:
|
||||||
|
- Internal trust-boundary assumptions the project already documents
|
||||||
|
- Defense-in-depth ideas with no concrete attack path
|
||||||
|
|
||||||
|
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write `explorer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: explorer
|
||||||
|
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- Glob/Grep for searches
|
||||||
|
- Read only for files you need to quote from
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
- Refactor, edit, or "improve" anything
|
||||||
|
- Read files that aren't relevant to the question
|
||||||
|
- Dump raw tool output — summarize
|
||||||
|
|
||||||
|
Output style:
|
||||||
|
- Lead with the answer in one sentence.
|
||||||
|
- Back it up with file:line references.
|
||||||
|
- If you found nothing, say "no match" and what you searched for.
|
||||||
|
|
||||||
|
Keep responses short. The caller wants facts, not prose.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write `researcher.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: researcher
|
||||||
|
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
|
||||||
|
|
||||||
|
Good fits:
|
||||||
|
- Summarizing documents, specs, or long outputs
|
||||||
|
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
|
||||||
|
- Drafting non-code text (release notes, emails, docs)
|
||||||
|
- Analyzing structured data (logs, CSV, JSON) and reporting findings
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Restate the task in one sentence so you know what "done" looks like.
|
||||||
|
2. Gather just enough information — stop when you can answer, not when you run out of sources.
|
||||||
|
3. Distinguish facts ("the file says X") from inference ("so likely Y").
|
||||||
|
4. Cite sources (file:line, URL, log excerpt) for every claim.
|
||||||
|
|
||||||
|
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/DefaultAgents/
|
||||||
|
git commit -m "feat(worker): add bundled default agent definitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Wire bundled agents into build output
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Content item group for DefaultAgents**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`. After the existing `<ItemGroup>` blocks and before the final `<PropertyGroup>`, add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="DefaultAgents\*.md">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the worker and verify the files land in output**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
Then verify output:
|
||||||
|
|
||||||
|
Run: `ls src/ClaudeDo.Worker/bin/Debug/net8.0/DefaultAgents/`
|
||||||
|
Expected: all 6 `.md` files present.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
git commit -m "build(worker): ship DefaultAgents folder in build output"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Write DefaultAgentSeeder tests (failing)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||||
|
|
||||||
|
The service doesn't exist yet — these tests will fail to compile initially. That's fine; the next task implements it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the test file**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class DefaultAgentSeederTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _bundleDir;
|
||||||
|
private readonly string _targetDir;
|
||||||
|
|
||||||
|
public DefaultAgentSeederTests()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
|
||||||
|
_bundleDir = Path.Combine(root, "bundle");
|
||||||
|
_targetDir = Path.Combine(root, "target");
|
||||||
|
Directory.CreateDirectory(_bundleDir);
|
||||||
|
Directory.CreateDirectory(_targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteBundleAsync(string name, string content)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await WriteBundleAsync("b.md", "B");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_SkipsExistingFiles()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "bundled");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Copied);
|
||||||
|
Assert.Equal(1, result.Skipped);
|
||||||
|
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
|
||||||
|
Assert.Equal("user-modified", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await WriteBundleAsync("b.md", "B");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.Equal(1, result.Skipped);
|
||||||
|
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
|
||||||
|
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
|
||||||
|
{
|
||||||
|
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
||||||
|
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_CreatesTargetDir_IfMissing()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
var missingTarget = Path.Combine(_targetDir, "nested", "created");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_IgnoresNonMarkdownFiles()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm they fail to compile**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
|
||||||
|
Expected: compile error — `The type or namespace name 'DefaultAgentSeeder' could not be found`.
|
||||||
|
|
||||||
|
This confirms the tests target the not-yet-written service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Implement DefaultAgentSeeder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the service**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
public sealed record SeedResult(int Copied, int Skipped);
|
||||||
|
|
||||||
|
public sealed class DefaultAgentSeeder
|
||||||
|
{
|
||||||
|
private readonly string _bundleDir;
|
||||||
|
private readonly string _targetDir;
|
||||||
|
private readonly ILogger<DefaultAgentSeeder>? _logger;
|
||||||
|
|
||||||
|
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
|
||||||
|
{
|
||||||
|
_bundleDir = bundleDir;
|
||||||
|
_targetDir = targetDir;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_bundleDir))
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
|
||||||
|
return new SeedResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_targetDir);
|
||||||
|
|
||||||
|
int copied = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var fileName = Path.GetFileName(src);
|
||||||
|
var dst = Path.Combine(_targetDir, fileName);
|
||||||
|
|
||||||
|
if (File.Exists(dst))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var input = File.OpenRead(src);
|
||||||
|
using var output = File.Create(dst);
|
||||||
|
await input.CopyToAsync(output, ct);
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SeedResult(copied, skipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests and verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
|
||||||
|
Expected: 6 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
|
||||||
|
git commit -m "feat(worker): add DefaultAgentSeeder for first-launch agent seeding"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Wire seeder into Worker startup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register seeder and run SeedMissingAsync at startup**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Program.cs`, replace the "Agent file management." block (currently lines 36–39):
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```csharp
|
||||||
|
// Agent file management.
|
||||||
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||||
|
Directory.CreateDirectory(agentsDir);
|
||||||
|
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
// Agent file management.
|
||||||
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||||
|
Directory.CreateDirectory(agentsDir);
|
||||||
|
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
||||||
|
|
||||||
|
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
||||||
|
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
||||||
|
defaultAgentsBundleDir,
|
||||||
|
agentsDir,
|
||||||
|
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, after `var app = builder.Build();` and before `app.MapHub<WorkerHub>("/hub");`, add:
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```csharp
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
ClaudeDoDbContext.MigrateAndConfigure(
|
||||||
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
ClaudeDoDbContext.MigrateAndConfigure(
|
||||||
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
|
||||||
|
var seedResult = await seeder.SeedMissingAsync();
|
||||||
|
app.Logger.LogInformation(
|
||||||
|
"Default agents seeded: {Copied} copied, {Skipped} already present",
|
||||||
|
seedResult.Copied, seedResult.Skipped);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build worker to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Program.cs
|
||||||
|
git commit -m "feat(worker): seed default agents on startup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Add RestoreDefaultAgents hub method (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
|
||||||
|
The existing `AgentSettingsHubTests` doesn't exercise `WorkerHub` directly (it tests the repository). We'll add a new test file that tests the seeder restore flow end-to-end without SignalR plumbing — constructing the seeder with temp dirs and asserting the `SeedResult` round-trip. This mirrors how the file is structured today and keeps tests simple.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a restore test**
|
||||||
|
|
||||||
|
Add to `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`. Inside the existing `AgentSettingsHubTests` class (before the closing brace), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
|
||||||
|
var bundleDir = Path.Combine(root, "bundle");
|
||||||
|
var targetDir = Path.Combine(root, "target");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(bundleDir);
|
||||||
|
Directory.CreateDirectory(targetDir);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
|
||||||
|
|
||||||
|
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(root, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to confirm it passes (seeder already exists)**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentSettingsHubTests.RestoreDefaultAgents_CopiesMissingBundledFiles"`
|
||||||
|
Expected: 1 test passes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add RestoreDefaultAgents to WorkerHub**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
|
||||||
|
|
||||||
|
Add a new DTO record near the top with the other DTOs (after the `ListConfigDto` line on line 30):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record SeedResultDto(int Copied, int Skipped);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the class field block. Find:
|
||||||
|
```csharp
|
||||||
|
private readonly QueueService _queue;
|
||||||
|
private readonly AgentFileService _agentService;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||||
|
private readonly TaskResetService _resetService;
|
||||||
|
private readonly TaskMergeService _mergeService;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
private readonly QueueService _queue;
|
||||||
|
private readonly AgentFileService _agentService;
|
||||||
|
private readonly DefaultAgentSeeder _seeder;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||||
|
private readonly TaskResetService _resetService;
|
||||||
|
private readonly TaskMergeService _mergeService;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the constructor. Find:
|
||||||
|
```csharp
|
||||||
|
public WorkerHub(
|
||||||
|
QueueService queue,
|
||||||
|
AgentFileService agentService,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
|
TaskResetService resetService,
|
||||||
|
TaskMergeService mergeService)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_agentService = agentService;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_wtMaintenance = wtMaintenance;
|
||||||
|
_resetService = resetService;
|
||||||
|
_mergeService = mergeService;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
public WorkerHub(
|
||||||
|
QueueService queue,
|
||||||
|
AgentFileService agentService,
|
||||||
|
DefaultAgentSeeder seeder,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
|
TaskResetService resetService,
|
||||||
|
TaskMergeService mergeService)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_agentService = agentService;
|
||||||
|
_seeder = seeder;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_wtMaintenance = wtMaintenance;
|
||||||
|
_resetService = resetService;
|
||||||
|
_mergeService = mergeService;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the hub method. After the existing `RefreshAgents` method (currently line 126):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<SeedResultDto> RestoreDefaultAgents()
|
||||||
|
{
|
||||||
|
var result = await _seeder.SeedMissingAsync();
|
||||||
|
return new SeedResultDto(result.Copied, result.Skipped);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build worker to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all Worker tests to confirm no regressions**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
|
||||||
|
git commit -m "feat(worker): expose RestoreDefaultAgents hub method"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Add RestoreDefaultAgentsAsync to WorkerClient
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the DTO record**
|
||||||
|
|
||||||
|
At the bottom of `src/ClaudeDo.Ui/Services/WorkerClient.cs`, alongside the other public record declarations (after `ListConfigDto`, currently line 350), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the client method**
|
||||||
|
|
||||||
|
In the `WorkerClient` class, after the `RefreshAgentsAsync` method (currently line 232), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build UI to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||||
|
git commit -m "feat(ui): add RestoreDefaultAgentsAsync to WorkerClient"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Add restore button to Settings modal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the command to the viewmodel**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`, add a new command method. Place it after the existing `ConfirmResetAll` method (currently ending line 162), before the `OpenPath` method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestoreDefaultAgents()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.RestoreDefaultAgentsAsync();
|
||||||
|
if (result is null)
|
||||||
|
StatusMessage = "Worker offline.";
|
||||||
|
else if (result.Copied == 0 && result.Skipped == 0)
|
||||||
|
StatusMessage = "No default agents bundled.";
|
||||||
|
else if (result.Copied == 0)
|
||||||
|
StatusMessage = "All default agents already present.";
|
||||||
|
else
|
||||||
|
StatusMessage = $"Restored {result.Copied} default agent(s).";
|
||||||
|
|
||||||
|
await _worker.RefreshAgentsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Restore failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a button to the settings view**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`. Add a new section after the `WORKTREES` StackPanel block (which ends with `</StackPanel>` around line 185) and before the `ABOUT` section (`<!-- ABOUT -->` around line 187).
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```xml
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ABOUT -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```xml
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- AGENTS -->
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="section-label" Text="AGENTS"/>
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
|
||||||
|
FontSize="11"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<Button Content="Restore default agents"
|
||||||
|
Command="{Binding RestoreDefaultAgentsCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ABOUT -->
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build UI to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke test**
|
||||||
|
|
||||||
|
Start the worker and the UI. Open Settings (3-dots next to username). The AGENTS section should appear with a "Restore default agents" button.
|
||||||
|
|
||||||
|
1. With `~/.todo-app/agents/` empty (delete any existing `.md` files first, back them up if needed): click the button. Status should read "Restored N default agent(s)." The files should appear in the folder.
|
||||||
|
2. Click again. Status should read "All default agents already present."
|
||||||
|
3. Modify one of the restored files. Click restore. The modified file content should be preserved.
|
||||||
|
|
||||||
|
If any step fails, stop and fix before committing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
|
||||||
|
git commit -m "feat(ui): add Restore default agents button to Settings modal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
At the end, run the full test suite and build all projects:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all builds succeed, all tests pass.
|
||||||
|
|
||||||
|
Additionally, start the Worker once with an empty `~/.todo-app/agents/` folder and confirm the log line:
|
||||||
|
> `Default agents seeded: 6 copied, 0 already present`
|
||||||
|
|
||||||
|
Then confirm `~/.todo-app/agents/` contains all 6 markdown files.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
927
docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md
Normal file
927
docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
# Planning Sessions — Plan C: UI 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:** Wire the planning-session feature into the UI: context-menu entries, hierarchical display of parent + children, draft styling, unfinished-session dialog, and the `WorkerClient` methods that call the hub endpoints built in Plan B.
|
||||||
|
|
||||||
|
**Architecture:** Extend `TaskRowViewModel` with hierarchy-aware flags (`IsChild`, `IsPlanningParent`, `IsExpanded`). `TasksIslandViewModel` builds a flat stream that interleaves parents and their children based on expanded state. Context-menu entries on `TaskRowView` gate on task status. Draft styling lives in the existing island styles. A modal dialog reuses the project's `TaskCompletionSource<T>` pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`), compiled bindings, SignalR client.
|
||||||
|
|
||||||
|
**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` section 6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisite Gate
|
||||||
|
|
||||||
|
This plan depends on Plan A being merged to `main`. Plan B's interface contract (hub method names, return types) is locked in the spec §6.6 and Plan B task 13 — this plan can proceed in parallel with Plan B.
|
||||||
|
|
||||||
|
Before starting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin main
|
||||||
|
git checkout main
|
||||||
|
git pull --ff-only
|
||||||
|
ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
If the file is missing, wait for Plan A:
|
||||||
|
```bash
|
||||||
|
while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do
|
||||||
|
echo "Waiting for Plan A to merge..."
|
||||||
|
sleep 60
|
||||||
|
git fetch origin main && git pull --ff-only
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Then branch:
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/planning-sessions-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parallel-with-Plan-B note:** Plan B may not yet be merged when this plan runs. The `WorkerClient` methods in Task 9 will compile against Plan B's SignalR hub method names (they're string-based SignalR invocations), so they don't have a build-time dependency. Runtime end-to-end testing requires Plan B merged; until then, mock-test what's possible and smoke-test manually once both plans land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — add `ParentTaskId`, `IsChild`, `IsPlanningParent`, `IsExpanded`, `PlanningBadge` properties.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — add planning commands, expanded-state map, flat-stream rebuild logic.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — chevron, indentation, badges, draft styling hooks.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs` — context-menu event handlers (if code-behind is used; else inline).
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` — use the extended TaskRowView template.
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — five new hub method wrappers matching Plan B.
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `.draft`, `.planning-parent`, `.planned-parent`, badge styles.
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs` — modal Resume/Finalize/Discard dialog.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs` — dialog VM.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — VM-level tests.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs` — VM-level tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend `TaskRowViewModel`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for planning flags**
|
||||||
|
|
||||||
|
Create the test file. Adapt the existing `TaskRowViewModelTests` pattern (look at `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` for how VMs are constructed in tests):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||||
|
|
||||||
|
public sealed class TaskRowViewModelPlanningTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull()
|
||||||
|
{
|
||||||
|
// Adapt the constructor call to your actual TaskRowViewModel signature (see TaskRowViewModelTests).
|
||||||
|
var vm = TestHelpers.MakeRow(
|
||||||
|
status: "draft",
|
||||||
|
parentTaskId: "parent-id");
|
||||||
|
|
||||||
|
Assert.True(vm.IsChild);
|
||||||
|
Assert.False(vm.IsPlanningParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planning_Status_SetsIsPlanningParent()
|
||||||
|
{
|
||||||
|
var vm = TestHelpers.MakeRow(status: "planning", parentTaskId: null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsPlanningParent);
|
||||||
|
Assert.False(vm.IsChild);
|
||||||
|
Assert.Equal("PLANNING", vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planned_Status_ShowsPlannedBadge()
|
||||||
|
{
|
||||||
|
var vm = TestHelpers.MakeRow(status: "planned", parentTaskId: null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsPlanningParent);
|
||||||
|
Assert.Equal("PLANNED", vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonPlanningStatus_NoBadge()
|
||||||
|
{
|
||||||
|
var vm = TestHelpers.MakeRow(status: "manual", parentTaskId: null);
|
||||||
|
|
||||||
|
Assert.False(vm.IsPlanningParent);
|
||||||
|
Assert.Null(vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TestHelpers
|
||||||
|
{
|
||||||
|
public static TaskRowViewModel MakeRow(string status, string? parentTaskId)
|
||||||
|
{
|
||||||
|
// Implement based on actual TaskRowViewModel constructor.
|
||||||
|
// The TaskRowViewModelTests.cs file in the same folder shows the existing pattern.
|
||||||
|
throw new NotImplementedException("Adapt to your TaskRowViewModel constructor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` first to see how the VM is constructed in tests, then fill in `TestHelpers.MakeRow` accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run; verify fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRowViewModelPlanningTests"`
|
||||||
|
Expected: FAIL (properties not yet on VM).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend the VM**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` add the new properties using `[ObservableProperty]`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private string? parentTaskId;
|
||||||
|
[ObservableProperty] private bool isExpanded = true;
|
||||||
|
|
||||||
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
|
public bool IsPlanningParent => string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(Status, "planned", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public string? PlanningBadge => Status switch
|
||||||
|
{
|
||||||
|
string s when string.Equals(s, "planning", StringComparison.OrdinalIgnoreCase) => "PLANNING",
|
||||||
|
string s when string.Equals(s, "planned", StringComparison.OrdinalIgnoreCase) => "PLANNED",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool IsDraft => string.Equals(Status, "draft", StringComparison.OrdinalIgnoreCase);
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `IsChild`, `IsPlanningParent`, `PlanningBadge`, and `IsDraft` are computed from other observables, you must raise property-changed notifications when `Status` or `ParentTaskId` changes. Use `[ObservableProperty]` partial methods:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
partial void OnStatusChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
|
OnPropertyChanged(nameof(PlanningBadge));
|
||||||
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnParentTaskIdChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsChild));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the existing VM already has `OnStatusChanged` (check for generator outputs), merge into it rather than duplicating.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run; verify pass**
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs
|
||||||
|
git commit -m "feat(ui): TaskRowViewModel gains planning hierarchy flags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `WorkerClient` planning methods
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Create: DTOs matching Plan B return types (either inline in the client file or new file `src/ClaudeDo.Ui/Services/PlanningDtos.cs`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DTOs**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Ui/Services/PlanningDtos.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
public sealed record PlanningSessionFilesDto(
|
||||||
|
string SessionDirectory,
|
||||||
|
string McpConfigPath,
|
||||||
|
string SystemPromptPath,
|
||||||
|
string InitialPromptPath);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionStartInfo(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
PlanningSessionFilesDto Files);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionResumeInfo(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string ClaudeSessionId,
|
||||||
|
string McpConfigPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
These field names must match Plan B's `PlanningSessionStartContext` / `PlanningSessionResumeContext` exactly (case-sensitive JSON deserialization through SignalR).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `WorkerClient` methods**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||||
|
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `_connection` with whatever name the existing `WorkerClient` uses for its `HubConnection` field.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/PlanningDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||||
|
git commit -m "feat(ui): WorkerClient planning-session methods"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `TasksIslandViewModel` — planning commands + expanded state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add commands to the VM**
|
||||||
|
|
||||||
|
In `TasksIslandViewModel.cs`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly Dictionary<string, bool> _expandedState = new();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !string.Equals(row.Status, "manual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.StartPlanningSessionAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _dialogs.ShowErrorAsync("Could not start planning session", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.IsPlanningParent) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.ResumePlanningSessionAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _dialogs.ShowErrorAsync("Could not resume planning session", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
var confirm = await _dialogs.ConfirmAsync(
|
||||||
|
"Discard planning session?",
|
||||||
|
"This will delete all draft tasks and reset the parent to Manual.");
|
||||||
|
if (!confirm) return;
|
||||||
|
await _workerClient.DiscardPlanningSessionAsync(row.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
await _workerClient.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleExpand(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : true);
|
||||||
|
_expandedState[row.Id] = next;
|
||||||
|
row.IsExpanded = next;
|
||||||
|
RebuildFlatStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildFlatStreams()
|
||||||
|
{
|
||||||
|
// Existing code builds OpenItems/CompletedItems from the task list.
|
||||||
|
// Modify it so that: after emitting a parent, if IsPlanningParent && IsExpanded,
|
||||||
|
// its Draft / Manual / Queued / Running / Done children are emitted next.
|
||||||
|
// Children already know they are children (ParentTaskId != null) and are styled as such.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `RebuildFlatStreams` (or equivalent) probably just groups tasks by status. You need to intersperse the hierarchy:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Pseudocode — fit to the existing code shape.
|
||||||
|
var topLevel = allRows.Where(r => !r.IsChild).OrderBy(r => r.SortOrder);
|
||||||
|
var flat = new List<TaskRowViewModel>();
|
||||||
|
foreach (var parent in topLevel)
|
||||||
|
{
|
||||||
|
flat.Add(parent);
|
||||||
|
if (parent.IsPlanningParent && parent.IsExpanded)
|
||||||
|
{
|
||||||
|
var children = allRows
|
||||||
|
.Where(r => r.ParentTaskId == parent.Id)
|
||||||
|
.OrderBy(r => r.SortOrder)
|
||||||
|
.ToList();
|
||||||
|
flat.AddRange(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Then bucket `flat` into OpenItems/CompletedItems like today, preserving order.
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass dependencies: the VM already has a `WorkerClient` or equivalent — reuse it. Add a dialog service if not already injected:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IDialogService
|
||||||
|
{
|
||||||
|
Task<bool> ConfirmAsync(string title, string message);
|
||||||
|
Task ShowErrorAsync(string title, string message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If an analog already exists (check existing editor dialogs), use it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing VM tests**
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||||
|
|
||||||
|
public sealed class TasksIslandViewModelPlanningTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
|
||||||
|
{
|
||||||
|
// Arrange: create VM with one Planning parent and two Draft children.
|
||||||
|
// Act: call ToggleExpandCommand with the parent.
|
||||||
|
// Assert: flat stream no longer contains the children.
|
||||||
|
// Adapt to how the existing TasksIslandViewModel is instantiated.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenPlanningSessionCommand_ManualTaskOnly_CanExecuteTrue()
|
||||||
|
{
|
||||||
|
// Arrange VM with a Manual row.
|
||||||
|
// Assert CanExecute for OpenPlanningSession command is true for Manual rows,
|
||||||
|
// false for Queued/Running/Done/Failed rows.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These are skeleton tests — implement with the same construction pattern used by the existing `TasksIslandViewModelTests` if one exists, or build a minimal VM fake with a stub `WorkerClient`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build + test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TasksIslandViewModelPlanningTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||||
|
git commit -m "feat(ui): planning commands and expand/collapse in TasksIslandViewModel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `TaskRowView` — indent, chevron, badges, draft styling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Wrap the row content with a Grid that has an indent column**
|
||||||
|
|
||||||
|
Open `TaskRowView.axaml`. The existing root is likely a `Grid` or `Border`. Replace/refactor the top-level layout to:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Width="24"
|
||||||
|
IsVisible="{Binding IsChild}">
|
||||||
|
<Rectangle Width="1" Fill="{DynamicResource TextFaintBrush}" HorizontalAlignment="Right" Margin="0,4"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Column="1" ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<!-- Chevron for planning parents -->
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Classes="icon-btn chevron"
|
||||||
|
Width="18" Height="18"
|
||||||
|
IsVisible="{Binding IsPlanningParent}"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<PathIcon Width="10" Height="10">
|
||||||
|
<PathIcon.Data>
|
||||||
|
<MultiBinding Converter="{StaticResource ChevronDataConverter}">
|
||||||
|
<Binding Path="IsExpanded"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</PathIcon.Data>
|
||||||
|
</PathIcon>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- existing title/description area -->
|
||||||
|
<StackPanel Grid.Column="1" ...>
|
||||||
|
<!-- existing title binding with added italic when IsDraft -->
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalicConverter}}"
|
||||||
|
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacityConverter}}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
||||||
|
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||||
|
<TextBlock Text="DRAFT"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
|
||||||
|
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a structural edit — preserve all existing bindings for status color, completion toggle, star, scheduled-for, etc. The new indentation column and badges are additive.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the converters**
|
||||||
|
|
||||||
|
If `ChevronDataConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter` do not exist, add them to `src/ClaudeDo.Ui/Converters/` (or inline as compiled converters). Example inline:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- in UserControl.Resources of TaskRowView.axaml, or in App.axaml for global -->
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="3"/>
|
||||||
|
<Setter Property="Padding" Value="4,1"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource BadgeBgBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.draft">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.planning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
```
|
||||||
|
|
||||||
|
If converters must be code-based, a minimal `BoolToItalicConverter`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToItalicConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in `App.axaml` resources.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds cleanly (XAML compiles).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Converters/
|
||||||
|
git commit -m "feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: `TaskRowView` — planning context-menu entries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the existing context menu**
|
||||||
|
|
||||||
|
Open `TaskRowView.axaml`. The ContextMenu lives somewhere on the root element or as a `ContextMenu.Items`/`ContextFlyout`. Find the block that defines entries like "Edit", "Run now", etc.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insert planning entries conditionally**
|
||||||
|
|
||||||
|
Add within the existing menu (order: after "Run now" and a separator):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<MenuItem Header="Open planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding Status, Converter={StaticResource IsManualAndNotChildConverter}, ConverterParameter={Binding IsChild}}"/>
|
||||||
|
|
||||||
|
<MenuItem Header="Resume planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
|
||||||
|
|
||||||
|
<MenuItem Header="Discard planning session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Simpler alternative without multi-condition converters: expose direct bool VM properties that combine the logic — `CanOpenPlanningSession`, `CanResumePlanningSession`, `CanDiscardPlanningSession`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In TaskRowViewModel
|
||||||
|
public bool CanOpenPlanningSession =>
|
||||||
|
string.Equals(Status, "manual", StringComparison.OrdinalIgnoreCase) && !IsChild;
|
||||||
|
|
||||||
|
public bool CanResumeOrDiscardPlanning =>
|
||||||
|
string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `OnPropertyChanged(nameof(CanOpenPlanningSession))` and friends in the status/parent-id partial methods from Task 1. Then the XAML simplifies to:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<MenuItem Header="Open planning Session"
|
||||||
|
Command="{Binding ...OpenPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this simpler path — cleaner.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
|
||||||
|
git commit -m "feat(ui): planning entries in task context menu"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Island styles — draft, badges
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add brushes + styles**
|
||||||
|
|
||||||
|
Append within `<Styles.Resources>` or wherever brushes are defined:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
|
||||||
|
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
|
||||||
|
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add styles:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="3"/>
|
||||||
|
<Setter Property="Padding" Value="4,1"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge > TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="9"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.draft">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planned">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and manually verify**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Launch app, create a task, right-click, use Open planning Session (if Plan B merged) or simulate via DB. Verify badge + italic draft rendering visually.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
|
||||||
|
git commit -m "feat(ui): draft and planning badge styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Unfinished-planning-session dialog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs`
|
||||||
|
- Create: `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the VM**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
public enum UnfinishedPlanningDialogResult
|
||||||
|
{
|
||||||
|
Cancel,
|
||||||
|
Resume,
|
||||||
|
FinalizeNow,
|
||||||
|
Discard,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class UnfinishedPlanningDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private string title = "Unfinished planning session";
|
||||||
|
[ObservableProperty] private string taskTitle = "";
|
||||||
|
[ObservableProperty] private int draftCount;
|
||||||
|
|
||||||
|
public TaskCompletionSource<UnfinishedPlanningDialogResult> Result { get; } = new();
|
||||||
|
|
||||||
|
[RelayCommand] private void Resume() => Result.TrySetResult(UnfinishedPlanningDialogResult.Resume);
|
||||||
|
[RelayCommand] private void FinalizeNow() => Result.TrySetResult(UnfinishedPlanningDialogResult.FinalizeNow);
|
||||||
|
[RelayCommand] private void Discard() => Result.TrySetResult(UnfinishedPlanningDialogResult.Discard);
|
||||||
|
[RelayCommand] private void Cancel() => Result.TrySetResult(UnfinishedPlanningDialogResult.Cancel);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the view**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Dialogs"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Dialogs.UnfinishedPlanningDialog"
|
||||||
|
x:DataType="vm:UnfinishedPlanningDialogViewModel"
|
||||||
|
Width="440" Height="220"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
Title="{Binding Title}">
|
||||||
|
<StackPanel Margin="20" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="15"/>
|
||||||
|
<TextBlock Text="{Binding TaskTitle}" Opacity="0.85"/>
|
||||||
|
<TextBlock>
|
||||||
|
<Run Text="{Binding DraftCount}"/>
|
||||||
|
<Run Text=" draft tasks waiting to be finalized."/>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Discard" Command="{Binding DiscardCommand}"/>
|
||||||
|
<Button Content="Finalize now" Command="{Binding FinalizeNowCommand}"/>
|
||||||
|
<Button Content="Resume" Classes="accent" Command="{Binding ResumeCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
`UnfinishedPlanningDialog.axaml.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class UnfinishedPlanningDialog : Window
|
||||||
|
{
|
||||||
|
public UnfinishedPlanningDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire into `TasksIslandViewModel`**
|
||||||
|
|
||||||
|
When the user right-clicks a `Planning` row OR when the app starts and a `Planning` row is present, show the dialog. Add a helper in the VM:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task<UnfinishedPlanningDialogResult> AskUnfinishedPlanningAsync(TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
var dialogVm = new UnfinishedPlanningDialogViewModel
|
||||||
|
{
|
||||||
|
TaskTitle = row.Title,
|
||||||
|
DraftCount = await _workerClient.GetPendingDraftCountAsync(row.Id),
|
||||||
|
};
|
||||||
|
var dlg = new UnfinishedPlanningDialog { DataContext = dialogVm };
|
||||||
|
_ = dlg.ShowDialog(_ownerWindow);
|
||||||
|
return await dialogVm.Result.Task;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the direct resume/discard/finalize commands (from Task 3) with calls that first pop this dialog and dispatch based on result. For example:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.IsPlanningParent) return;
|
||||||
|
var choice = await AskUnfinishedPlanningAsync(row);
|
||||||
|
switch (choice)
|
||||||
|
{
|
||||||
|
case UnfinishedPlanningDialogResult.Resume:
|
||||||
|
await _workerClient.ResumePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningDialogResult.FinalizeNow:
|
||||||
|
await _workerClient.FinalizePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningDialogResult.Discard:
|
||||||
|
await _workerClient.DiscardPlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build + manual run**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml.cs src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||||
|
git commit -m "feat(ui): unfinished planning session dialog"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: TasksIslandView — wire new templates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: No structural change required**
|
||||||
|
|
||||||
|
The hierarchy is already handled by `TasksIslandViewModel.RebuildFlatStreams` interleaving children into `OpenItems`/`CompletedItems`. The existing `ItemsControl` bindings in `TasksIslandView` automatically pick up the new rows. Indentation/chevron/badge rendering is entirely inside `TaskRowView` (Task 4).
|
||||||
|
|
||||||
|
Verify the view does not have any logic that filters out children based on `ParentTaskId IS NOT NULL` today. If it does, remove that filter — the VM is now authoritative about what's in the stream.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build + manual check**
|
||||||
|
|
||||||
|
Launch the UI, create a manual task, and manually update its status to `Planning` in the DB (or wait for Plan B). Create one child in DB. Verify indentation and chevron render.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit (if any change was made)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
|
||||||
|
git commit -m "chore(ui): verify tasks view renders hierarchy via flat stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
If no change — skip the commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Delete-with-children handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (existing delete command)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Catch `DbUpdateException` from delete**
|
||||||
|
|
||||||
|
Find the existing delete command. Wrap the repository/hub call:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RemoveAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.DeleteTaskAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (HubException ex) when (ex.Message.Contains("foreign key", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ex.Message.Contains("Restrict", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var childrenCount = 1; // or query via a new hub method if exact count matters
|
||||||
|
var choice = await _dialogs.ConfirmAsync(
|
||||||
|
"Cannot delete",
|
||||||
|
$"This task has child tasks. Delete all including children?");
|
||||||
|
if (!choice) return;
|
||||||
|
// Recursive delete — iterate children first. For v1 MVP, instruct user to
|
||||||
|
// discard the planning session first. Simpler, safer.
|
||||||
|
await _dialogs.ShowErrorAsync(
|
||||||
|
"Cannot delete",
|
||||||
|
"Discard the planning session or delete child tasks manually first.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simplification for v1:** do not implement "Delete all including children" yet. Show an error instructing the user to discard the planning session or delete children first. This avoids an additional hub endpoint and keeps Plan C bounded.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||||
|
git commit -m "feat(ui): friendly error when deleting task with children"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Manual smoke test + final verification
|
||||||
|
|
||||||
|
**Files:** none
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test run**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the full app**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
||||||
|
```
|
||||||
|
Expected: all succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke test (requires Plan B merged)**
|
||||||
|
|
||||||
|
1. Launch the app.
|
||||||
|
2. Create a Manual task with a title and some TODO-style description.
|
||||||
|
3. Right-click → "Open planning Session".
|
||||||
|
4. Verify Windows Terminal opens with Claude CLI running.
|
||||||
|
5. In the terminal, ask Claude to create two child tasks (`mcp__claudedo__create_child_task`).
|
||||||
|
6. Watch the UI: drafts appear under the parent (italic, grey, badge DRAFT).
|
||||||
|
7. Ask Claude to `finalize`.
|
||||||
|
8. Verify drafts become Manual/Queued children, parent flips to PLANNED badge.
|
||||||
|
9. Close terminal without finalize on a new planning task; right-click the Planning task: dialog appears with Resume/Finalize/Discard.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Document any UI tweaks needed in `docs/open.md`**
|
||||||
|
|
||||||
|
Add a checklist item under UI verification for planning session visuals.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/open.md
|
||||||
|
git commit -m "docs(open): add planning-session manual verification checklist"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope for Plan C
|
||||||
|
|
||||||
|
- Recursive delete of parent-with-children via UI (error-only in v1).
|
||||||
|
- Collapse-state persistence across app restarts (in-memory only).
|
||||||
|
- Keyboard shortcut for "Open planning Session".
|
||||||
|
- Visual differentiation for PLANNED parents beyond a badge (e.g., subtle background tint) — can be added later if visually needed.
|
||||||
1831
docs/superpowers/plans/2026-04-23-self-update.md
Normal file
1831
docs/superpowers/plans/2026-04-23-self-update.md
Normal file
File diff suppressed because it is too large
Load Diff
718
docs/superpowers/plans/2026-04-23-worker-log-footer.md
Normal file
718
docs/superpowers/plans/2026-04-23-worker-log-footer.md
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Continue & Reset Buttons for Failed Tasks
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a task ends in `Failed` status (Claude exited without marking the work done, cancelled mid-run, crashed, etc.), the user has no way to act on it from the UI:
|
||||||
|
|
||||||
|
- **Nudging the agent** is only possible via the hub method `ContinueTask`, which is not wired into the UI.
|
||||||
|
- **Rolling back** the worktree requires shelling into git manually to remove the branch and folder, then editing the task in the DB. In practice the worktree is just abandoned.
|
||||||
|
|
||||||
|
We want two explicit actions in the details pane for a failed task: **Continue** (resume the Claude session with a follow-up prompt) and **Reset** (discard the worktree and return the task to an editable `Manual` state).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Actions are shown **only when the selected task has `Status == Failed`**.
|
||||||
|
- `Continue` is the multi-turn mechanism already implemented in `TaskRunner.ContinueAsync` — this spec only wires it into the UI.
|
||||||
|
- `Reset` is new end-to-end (hub method, worktree discard, task status reset).
|
||||||
|
- Run history (`task_runs` rows) is **preserved** across a Reset for audit.
|
||||||
|
- Out of scope: Continue/Reset on `Done` tasks, undo of Reset, modifying the follow-up prompt before sending.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Both buttons live in `DetailsIslandView`, inside a new horizontal button row that is visible only when the currently selected task is `Failed`.
|
||||||
|
|
||||||
|
### Continue
|
||||||
|
|
||||||
|
- One-click. Sends the canned prompt `"Continue working on this task."` via `WorkerHub.ContinueTask(taskId, prompt)`.
|
||||||
|
- Enabled **only if** the task's latest `TaskRunEntity` has a non-null `SessionId`.
|
||||||
|
- When disabled, a tooltip reads `No session to resume`.
|
||||||
|
- No confirmation dialog.
|
||||||
|
|
||||||
|
### Reset
|
||||||
|
|
||||||
|
- Always enabled when the task is `Failed`.
|
||||||
|
- Opens a confirmation dialog:
|
||||||
|
> Discard worktree and reset task?
|
||||||
|
> This deletes branch `claudedo/<id>` and all uncommitted changes.
|
||||||
|
- On confirm, calls `WorkerHub.ResetTask(taskId)`.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### New hub method — `WorkerHub.ResetTask(string taskId)`
|
||||||
|
|
||||||
|
Preconditions:
|
||||||
|
|
||||||
|
- Task exists.
|
||||||
|
- Task status is **not** `Running`. If it is, throw — resetting a task that is actively executing would race with the runner.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
1. Load the task and its worktree (if any).
|
||||||
|
2. If a worktree exists and its `State == Active`, call `WorktreeManager.DiscardAsync(worktree, ct)` (see below).
|
||||||
|
3. Call `TaskRepository.ResetToManualAsync(taskId, ct)` to clear the result fields and flip the status.
|
||||||
|
4. Broadcast `TaskUpdated(taskId)`; broadcast `WorktreeUpdated(taskId)` if the worktree state changed.
|
||||||
|
|
||||||
|
If `WorktreeManager.DiscardAsync` throws (e.g. folder locked, branch checked out elsewhere), the hub method surfaces the error to the caller and leaves the task as `Failed` with the worktree still `Active`, so the user can retry. `TaskRepository.ResetToManualAsync` is **not** called in the failure path.
|
||||||
|
|
||||||
|
### New — `WorktreeManager.DiscardAsync(WorktreeEntity wt, CancellationToken ct)`
|
||||||
|
|
||||||
|
Shape mirrors the existing `CommitIfChangedAsync`. Steps:
|
||||||
|
|
||||||
|
1. `git worktree remove --force <wt.Path>` via `GitService`. The `--force` flag drops any uncommitted changes — expected, since the user already confirmed.
|
||||||
|
2. `git branch -D <wt.BranchName>` via `GitService`.
|
||||||
|
3. Update `WorktreeRepository`: set `State = Discarded`.
|
||||||
|
|
||||||
|
`GitService` gains two thin wrappers if they do not already exist: `WorktreeRemoveAsync(path, force: true)` and `BranchDeleteForceAsync(branch)`.
|
||||||
|
|
||||||
|
### New — `TaskRepository.ResetToManualAsync(string taskId, CancellationToken ct)`
|
||||||
|
|
||||||
|
Single UPDATE that sets:
|
||||||
|
|
||||||
|
- `Status = Manual`
|
||||||
|
- `Result = null`
|
||||||
|
- `StartedAt = null`
|
||||||
|
- `FinishedAt = null`
|
||||||
|
|
||||||
|
`LogPath` and the `task_runs` rows are left intact — they are the audit trail.
|
||||||
|
|
||||||
|
### Continue wiring
|
||||||
|
|
||||||
|
No backend changes. The UI calls `WorkerHub.ContinueTask(taskId, prompt)` and `TaskRunner.ContinueAsync` handles the rest.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### `DetailsIslandViewModel`
|
||||||
|
|
||||||
|
New members:
|
||||||
|
|
||||||
|
- `[ObservableProperty] bool showFailedActions` — true when the selected task's status is `Failed`.
|
||||||
|
- `[ObservableProperty] bool canContinue` — true when `showFailedActions` **and** the latest run of the selected task has a non-null `SessionId`.
|
||||||
|
- `[RelayCommand(CanExecute = nameof(CanContinue))] Task ContinueAsync()` — calls `HubClient.ContinueTask(task.Id, "Continue working on this task.")`.
|
||||||
|
- `[RelayCommand(CanExecute = nameof(ShowFailedActions))] Task ResetAsync()` — opens confirmation; on confirm, calls `HubClient.ResetTask(task.Id)`.
|
||||||
|
|
||||||
|
`ShowFailedActions` and `CanContinue` recompute whenever the selected task or its runs change (subscribe to the existing selection / task-updated signals).
|
||||||
|
|
||||||
|
### `DetailsIslandView.axaml`
|
||||||
|
|
||||||
|
A single `StackPanel` (orientation horizontal) inside the existing details layout, bound to `ShowFailedActions` for visibility, with two `Button`s wired to the commands.
|
||||||
|
|
||||||
|
### Confirmation dialog
|
||||||
|
|
||||||
|
Reuse the existing modal pattern (see `WorktreeModalView` for the shape). A minimal `ConfirmDialog` with title, body, `Cancel` + `Confirm` buttons is acceptable and reusable; if a simpler inline approach is idiomatic in this codebase, use that instead.
|
||||||
|
|
||||||
|
### `HubClient`
|
||||||
|
|
||||||
|
Add `Task ResetTask(string taskId)` alongside the existing `ContinueTask` wrapper.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Failure | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `ResetTask` called on a `Running` task | Hub throws; UI shows the error. The Reset button is CanExecute-gated anyway, so this is a defensive check. |
|
||||||
|
| `git worktree remove` fails | Hub throws; task stays `Failed`, worktree stays `Active`, user can retry or clean up manually. |
|
||||||
|
| `git branch -D` fails after worktree removal succeeded | Worktree state still gets set to `Discarded` (the folder is gone; leaving the branch dangling is less bad than leaving the DB out of sync). Log a warning. |
|
||||||
|
| `Continue` with no session_id | Button is disabled — the call cannot happen from the UI. Hub still guards with the existing `InvalidOperationException` in `ContinueAsync` for safety. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Integration tests (real SQLite, real git) in `ClaudeDo.Worker.Tests`:
|
||||||
|
|
||||||
|
1. **`WorktreeManager_DiscardAsync_removes_worktree_and_branch`** — create a worktree, call Discard, assert branch is gone from `git branch --list`, folder is gone, DB state is `Discarded`.
|
||||||
|
2. **`TaskRepository_ResetToManualAsync_clears_result_fields`** — seed a Failed task with Result/FinishedAt/StartedAt, call Reset, assert all cleared and status is Manual.
|
||||||
|
3. **`ResetTask_full_flow`** — seed a Failed task with an Active worktree and run history; invoke the hub method; assert status=Manual, worktree=Discarded, `task_runs` rows still present.
|
||||||
|
4. **`ResetTask_rejects_running_task`** — seed a Running task, assert the hub method throws and nothing is modified.
|
||||||
|
5. **`ResetTask_worktree_remove_failure_leaves_task_failed`** — simulate a git failure (e.g. lock the folder), assert task stays Failed and worktree stays Active.
|
||||||
|
|
||||||
|
No new UI tests — the commands are thin forwarders and are exercised manually.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None.
|
||||||
132
docs/superpowers/specs/2026-04-21-settings-modal-design.md
Normal file
132
docs/superpowers/specs/2026-04-21-settings-modal-design.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Settings Modal — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-21
|
||||||
|
**Status:** Approved for planning
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a general-settings modal reachable from the **⋯** button in the user footer of the Lists island (`ListsIslandView.axaml:68`). The modal exposes app-wide defaults for Claude runs, worktree behavior, and maintenance actions (cleanup / force-remove worktrees), plus a read-only "About" section with paths.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Claude defaults: instructions, model, max turns, permission mode
|
||||||
|
- Worktree defaults: strategy, central root, auto-cleanup toggle + days
|
||||||
|
- Worktree maintenance actions: cleanup finished, force-remove all
|
||||||
|
- About section: version, data/logs/config paths with "Open in Explorer"
|
||||||
|
- Single settings row persisted in SQLite
|
||||||
|
- SignalR surface for read/update + maintenance
|
||||||
|
- `ClaudeArgsBuilder` merge behavior for per-task overrides
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Worker-side infrastructure settings (hub URL, auto-start worker) — stays in `worker.config.json`
|
||||||
|
- Per-task "inherit defaults" toggle (always inherit; task values override per rule below)
|
||||||
|
- Any UI-layer tests (project has none today)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
New single-row entity `AppSettingsEntity` (Id = 1) in SQLite. Access via new `AppSettingsRepository` with `GetAsync` / `UpdateAsync`. Seeded by a new EF migration `AddAppSettings`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
| Column | Type | Seed default |
|
||||||
|
|---|---|---|
|
||||||
|
| `DefaultClaudeInstructions` | text | `""` |
|
||||||
|
| `DefaultModel` | string | `sonnet` |
|
||||||
|
| `DefaultMaxTurns` | int | `30` |
|
||||||
|
| `DefaultPermissionMode` | string | `acceptEdits` |
|
||||||
|
| `WorktreeStrategy` | string | `sibling` |
|
||||||
|
| `CentralWorktreeRoot` | string? | `null` |
|
||||||
|
| `WorktreeAutoCleanupEnabled` | bool | `false` |
|
||||||
|
| `WorktreeAutoCleanupDays` | int | `7` |
|
||||||
|
|
||||||
|
Rationale for DB over `worker.config.json`: transactional writes from the UI, no file-watcher dance, and the Worker already uses `IDbContextFactory<ClaudeDoDbContext>`.
|
||||||
|
|
||||||
|
### Merge rules for per-task overrides
|
||||||
|
|
||||||
|
`ClaudeArgsBuilder` gains a dependency on `AppSettingsRepository` and merges at build time:
|
||||||
|
|
||||||
|
- **Instructions:** `global + "\n\n" + task` (skip separator if either side empty)
|
||||||
|
- **Model / MaxTurns / PermissionMode:** `task ?? global` (task value wins when set)
|
||||||
|
|
||||||
|
No `TaskEntity` schema change.
|
||||||
|
|
||||||
|
### SignalR surface (new hub methods)
|
||||||
|
|
||||||
|
| Method | Returns | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GetAppSettings()` | `AppSettingsDto` | Single row |
|
||||||
|
| `UpdateAppSettings(dto)` | void | Full-row replace |
|
||||||
|
| `CleanupFinishedWorktrees()` | `int removedCount` | Skips Active |
|
||||||
|
| `ResetAllWorktrees()` | `{ removed, tasksAffected }` | Fails if any task is Running |
|
||||||
|
|
||||||
|
Maintenance logic lives in a new `WorktreeMaintenanceService` in the Worker; the hub stays thin. Service uses existing `GitService` + `WorktreeRepository`.
|
||||||
|
|
||||||
|
**Running-task guard:** `ResetAllWorktrees()` checks for any `Running` tasks before touching anything. If present, returns an error — the modal surfaces *"Cannot force-remove: N task(s) still running. Cancel them first."*
|
||||||
|
|
||||||
|
Affected worktrees after force-remove are marked `Discarded` in `WorktreeRepository`.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### Entry point
|
||||||
|
|
||||||
|
`ListsIslandView.axaml:68` ⋯ button binds to a new `OpenSettingsCommand` on `IslandsShellViewModel`. Command resolves `SettingsModalViewModel` and shows `SettingsModalView` via the existing modal pattern (`TaskCompletionSource<bool>` on save/cancel — same as `WorktreeModalView` / `DiffModalView`).
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Single scrollable modal, ~560 px wide, matches existing modal chrome (header eyebrow, monospace labels, close affordance). No tabs.
|
||||||
|
|
||||||
|
**Sections (top to bottom):**
|
||||||
|
|
||||||
|
1. **CLAUDE DEFAULTS** — instructions textarea (6 lines), model picker, max-turns numeric, permission-mode picker
|
||||||
|
2. **WORKTREES** — strategy picker, central-root folder picker, auto-cleanup toggle + days, then the two maintenance buttons
|
||||||
|
3. **ABOUT** — version (read-only), data folder / logs folder / worker.config path, each with "Open in Explorer" icon button
|
||||||
|
|
||||||
|
Footer: `[ Cancel ]` `[ Save ]`, right-aligned. Save button disabled while the form is invalid.
|
||||||
|
|
||||||
|
### Destructive action UX
|
||||||
|
|
||||||
|
- **Cleanup finished** — single click, inline result line under the button (`"Removed 3 worktree(s)."`), auto-clears ~4 s
|
||||||
|
- **Force-remove all** — click reveals an inline confirm row: *"Remove ALL N worktrees? Uncommitted work will be lost."* with red `Remove All` and neutral `Cancel`. Two-click confirm, no typed string (matches the delete-task confirm already in the app)
|
||||||
|
|
||||||
|
Both run against the worker over SignalR and leave the modal open on completion.
|
||||||
|
|
||||||
|
### Open-in-Explorer
|
||||||
|
|
||||||
|
Uses `Process.Start("explorer.exe", path)`. Windows-only is acceptable (app ships Windows-only via WPF installer).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Modal-side only, block `Save`:
|
||||||
|
|
||||||
|
- Max turns: integer 1–200
|
||||||
|
- Auto-cleanup days: integer 1–365 (required only when toggle is on)
|
||||||
|
- Central root: required when Strategy = Central; must be an existing directory
|
||||||
|
- Instructions: no length cap
|
||||||
|
|
||||||
|
Invalid fields get a red eyebrow with the specific error text.
|
||||||
|
|
||||||
|
## Testing (ClaudeDo.Worker.Tests)
|
||||||
|
|
||||||
|
- **`AppSettingsRepositoryTests`** — round-trip `Get` / `Update` on real SQLite
|
||||||
|
- **`ClaudeArgsBuilderTests`** — four merge cases: both empty, only global, only task, both set (prepend + separator behavior)
|
||||||
|
- **`WorktreeMaintenanceServiceTests`** — real git worktree fixtures:
|
||||||
|
- cleanup skips `Active`, removes `Merged` / `Discarded` / `Kept`
|
||||||
|
- force-remove fails while any task is `Running`
|
||||||
|
- force-remove succeeds otherwise and flips affected worktrees to `Discarded`
|
||||||
|
|
||||||
|
No UI-layer tests (project has none today).
|
||||||
|
|
||||||
|
## Build order (high level)
|
||||||
|
|
||||||
|
1. Data: entity + configuration + migration + repository
|
||||||
|
2. Worker: `WorktreeMaintenanceService` + `ClaudeArgsBuilder` wiring + hub methods + DTO
|
||||||
|
3. UI: SignalR client methods on `WorkerClient`, `SettingsModalViewModel`, `SettingsModalView`
|
||||||
|
4. Wire ⋯ button on `ListsIslandView` → `IslandsShellViewModel.OpenSettingsCommand`
|
||||||
|
5. Tests (Worker.Tests)
|
||||||
|
|
||||||
|
Detailed step-by-step sequencing belongs in the implementation plan, not here.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Stream Formatter Rewrite — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-21
|
||||||
|
**Scope:** `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`StreamLineFormatter` converts Claude CLI stream-json lines into human-readable
|
||||||
|
text for the Details pane. The current implementation only recognizes:
|
||||||
|
|
||||||
|
- `type=stream_event` — dead code (requires `--include-partial-messages`, which
|
||||||
|
the Worker does not pass)
|
||||||
|
- `type=result` — shown as `--- Result ---` block
|
||||||
|
- `type=system` with `subtype=api_retry`
|
||||||
|
|
||||||
|
Everything else — notably `assistant` and `user` messages that carry the actual
|
||||||
|
conversation and tool activity — falls through to `default: return null` and is
|
||||||
|
silently dropped. The Details pane is therefore mostly empty during a run,
|
||||||
|
while the raw `.log` file retains the full JSON.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Rewrite the formatter so every meaningful message type is rendered as one or
|
||||||
|
more compact text lines suitable for the live log in the Details pane. The
|
||||||
|
public API (`FormatLine(string)` / `FormatFile(string)`) and the existing
|
||||||
|
buffer/trim behavior in `DetailsIslandViewModel` stay the same.
|
||||||
|
|
||||||
|
## Input format
|
||||||
|
|
||||||
|
The Worker invokes the Claude CLI with:
|
||||||
|
|
||||||
|
```
|
||||||
|
claude -p --output-format stream-json --verbose --dangerously-skip-permissions ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Each stdout line is one complete SDK message. Top-level shapes relevant to the
|
||||||
|
formatter:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Session start
|
||||||
|
{"type":"system","subtype":"init","session_id":"…","model":"claude-…", …}
|
||||||
|
|
||||||
|
// API retry notification
|
||||||
|
{"type":"system","subtype":"api_retry", …}
|
||||||
|
|
||||||
|
// Assistant reply (text + tool calls)
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[
|
||||||
|
{"type":"text","text":"…"},
|
||||||
|
{"type":"tool_use","id":"toolu_…","name":"Read","input":{"file_path":"…"}}
|
||||||
|
]}, …}
|
||||||
|
|
||||||
|
// Tool result fed back to the model
|
||||||
|
{"type":"user","message":{"role":"user","content":[
|
||||||
|
{"tool_use_id":"toolu_…","type":"tool_result","content":"… or [ {type,text} ] …","is_error":false}
|
||||||
|
]}, "tool_use_result":{…optional rich payload…}, …}
|
||||||
|
|
||||||
|
// Final result
|
||||||
|
{"type":"result","result":"…", …}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes on quirks already observed in captured output:
|
||||||
|
|
||||||
|
- `tool_result.content` is sometimes a plain string, sometimes an array of
|
||||||
|
`{type:"text", text:"…"}` blocks. Handle both.
|
||||||
|
- The envelope may include `tool_use_result.file.numLines` / `file.filePath`
|
||||||
|
for Read-style results.
|
||||||
|
- Assistant messages may contain `thinking` blocks (filtered, not displayed).
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
One line per logical event. A trailing `\n` ends each line so the
|
||||||
|
`DetailsIslandViewModel` buffer splits cleanly.
|
||||||
|
|
||||||
|
| Input | Output |
|
||||||
|
|---|---|
|
||||||
|
| `system` / `init` | `[session <id8> · <model>]\n` |
|
||||||
|
| `system` / `api_retry` | `[Retrying API call...]\n` |
|
||||||
|
| `system` / other | `null` (filtered) |
|
||||||
|
| `assistant` text block | `<text>\n` (raw) |
|
||||||
|
| `assistant` tool_use block | `[<ToolLabel>] <arg>\n` (see below) |
|
||||||
|
| `assistant` thinking block | `null` (filtered) |
|
||||||
|
| `user` tool_result block | `→ <summary>\n` (see below) |
|
||||||
|
| `result` | `\n--- Result ---\n<text>\n` |
|
||||||
|
| unrecognized / parse failure | raw line (existing behavior for non-JSON) |
|
||||||
|
|
||||||
|
A single `assistant` message with N content blocks produces N output lines,
|
||||||
|
concatenated into one return string.
|
||||||
|
|
||||||
|
### Tool label + arg
|
||||||
|
|
||||||
|
Pick the most identifying input field per tool:
|
||||||
|
|
||||||
|
| Tool name | Display |
|
||||||
|
|---|---|
|
||||||
|
| `Read`, `Write`, `Edit`, `NotebookEdit` | `[<Tool>] <basename(file_path)>` |
|
||||||
|
| `Bash`, `PowerShell` | `[Bash] $ <command>` — truncate command at 120 chars, append `…` |
|
||||||
|
| `Grep` | `[Grep] "<pattern>"` |
|
||||||
|
| `Glob` | `[Glob] <pattern>` |
|
||||||
|
| `Task`, `Agent` | `[Task: <subagent_type>] <description>` (description truncated to 120) |
|
||||||
|
| `WebFetch` | `[WebFetch] <url>` |
|
||||||
|
| `WebSearch` | `[WebSearch] "<query>"` |
|
||||||
|
| `TodoWrite` | `[TodoWrite]` (no arg) |
|
||||||
|
| fallback | `[<name>]` |
|
||||||
|
|
||||||
|
Missing or empty input fields → emit the label only, no trailing text.
|
||||||
|
|
||||||
|
### tool_result summary
|
||||||
|
|
||||||
|
For each `tool_result` block in a `user` message, in priority order:
|
||||||
|
|
||||||
|
1. `is_error == true` → `→ error: <first non-empty line, trimmed, ≤120 chars>`
|
||||||
|
2. Envelope has `tool_use_result.file.numLines` → `→ <N> lines`
|
||||||
|
3. Content resolves to empty/whitespace string → `→ ok`
|
||||||
|
4. Otherwise → `→ <first non-empty line, ≤120 chars>` (append `…` if truncated)
|
||||||
|
|
||||||
|
Content resolution: if `content` is a string, use it; if it's an array, join
|
||||||
|
the `text` fields of `{type:"text"}` entries.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to `DetailsIslandViewModel` or the Worker pipeline.
|
||||||
|
- No collapsible/rich rendering — tool results stay one-liners.
|
||||||
|
- No persistence changes — the raw `.log` file still contains full JSON for
|
||||||
|
debugging.
|
||||||
|
- No unit tests in this change (separate workload).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Partial-token streaming (`--include-partial-messages`). The existing
|
||||||
|
`stream_event` branch is removed as dead code.
|
||||||
|
- Structured output / `--json-schema` rendering beyond the final `result`.
|
||||||
|
|
||||||
|
## Risks / edge cases
|
||||||
|
|
||||||
|
- **Unknown tool names** — fallback label `[<name>]` keeps output readable.
|
||||||
|
- **Malformed JSON inside a valid envelope** (e.g. missing `message.content`)
|
||||||
|
— skip the broken block, emit what we can; never throw.
|
||||||
|
- **Very long Bash commands or search queries** — 120-char truncation with `…`
|
||||||
|
keeps lines reasonable while preserving the prefix.
|
||||||
|
- **Binary or huge tool_result content** — summary rules 2–4 cap output at a
|
||||||
|
single line; full content stays in the raw log.
|
||||||
162
docs/superpowers/specs/2026-04-22-agent-settings-ui-design.md
Normal file
162
docs/superpowers/specs/2026-04-22-agent-settings-ui-design.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Design: Agent settings per list and per task (UI reimplementation)
|
||||||
|
|
||||||
|
Date: 2026-04-22
|
||||||
|
Status: Approved by user, implementation pending
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
During the recent UI rework, the editors for per-list and per-task agent settings were lost. The data layer and Worker still support them (`TaskEntity.Model/SystemPrompt/AgentPath`, `ListConfigEntity`, `TaskRunner` + `ClaudeArgsBuilder`), but the UI has zero references to these fields. Users currently cannot set model, custom system prompt, or agent file from the app.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Restore the ability to configure, per **list** and per **task**:
|
||||||
|
|
||||||
|
- `Model` — `opus` / `sonnet` / `haiku` / inherit
|
||||||
|
- `SystemPrompt` — free-text, appended to Claude's system prompt
|
||||||
|
- `AgentPath` — selection from agent files discovered by the Worker under `~/.todo-app/agents/*.md`
|
||||||
|
|
||||||
|
Per-task values override per-list values. Per-list values override global defaults from `worker.config.json`. This cascade is already implemented in `TaskRunner`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Agent file CRUD in the UI (read-only picker only)
|
||||||
|
- `--allowedTools`, `--bare`, permission modes (deferred, matches existing worker design notes)
|
||||||
|
- Any schema migration — the DB already has the required columns/tables
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
**Update-and-broadcast over SignalR.** UI never touches the DB directly; all writes go through new `WorkerHub` methods. Worker persists via repositories, then broadcasts `ListUpdated` / `TaskUpdated` so connected clients refresh.
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
### 1. Data layer additions
|
||||||
|
|
||||||
|
New repository `src/ClaudeDo.Data/Repositories/ListConfigRepository.cs`:
|
||||||
|
|
||||||
|
- `GetByListIdAsync(string listId, CancellationToken) -> ListConfigEntity?`
|
||||||
|
- `UpsertAsync(ListConfigEntity, CancellationToken) -> ListConfigEntity`
|
||||||
|
- `DeleteAsync(string listId, CancellationToken) -> bool`
|
||||||
|
|
||||||
|
New method on `ListRepository`:
|
||||||
|
|
||||||
|
- `UpdateAsync(ListEntity, CancellationToken)` — updates `Name`, `WorkingDir`, `DefaultCommitType`. Included because the consolidated list-settings modal edits these alongside agent fields.
|
||||||
|
|
||||||
|
New method on `TaskRepository`:
|
||||||
|
|
||||||
|
- `UpdateAgentSettingsAsync(string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken) -> bool`
|
||||||
|
- `null` values mean "inherit" (column is nulled out in DB).
|
||||||
|
- Kept as a narrow method to avoid widening `UpdateAsync`.
|
||||||
|
|
||||||
|
DI: register `ListConfigRepository` alongside other repos.
|
||||||
|
|
||||||
|
No migration — all columns/tables already exist.
|
||||||
|
|
||||||
|
### 2. SignalR hub surface
|
||||||
|
|
||||||
|
New DTOs in `src/ClaudeDo.Data/Dtos/` (project existing DTO pattern):
|
||||||
|
|
||||||
|
- `UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType)`
|
||||||
|
- `UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath)`
|
||||||
|
- `UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath)`
|
||||||
|
|
||||||
|
New methods on `WorkerHub`:
|
||||||
|
|
||||||
|
- `UpdateList(UpdateListDto dto)` — calls `ListRepository.UpdateAsync`, then broadcasts `ListUpdated(listId)`.
|
||||||
|
- `UpdateListConfig(UpdateListConfigDto dto)` — upserts via `ListConfigRepository.UpsertAsync`, broadcasts `ListUpdated(listId)`. If all three fields are null, calls `DeleteAsync` instead so the row doesn't linger empty.
|
||||||
|
- `UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)` — calls `TaskRepository.UpdateAgentSettingsAsync`, broadcasts `TaskUpdated(taskId)` (existing event).
|
||||||
|
|
||||||
|
New broadcast method on `HubBroadcaster`:
|
||||||
|
|
||||||
|
- `Task ListUpdatedAsync(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId);`
|
||||||
|
|
||||||
|
Loader endpoint to add:
|
||||||
|
|
||||||
|
- `GetListConfig(string listId)` — returns `(string? Model, string? SystemPrompt, string? AgentPath)` record, or `null` if no row. Used by `ListSettingsModal` and by `DetailsIslandViewModel` for effective-value inheritance display. Existing `GetLists` / `GetTasks` already cover the rest.
|
||||||
|
|
||||||
|
### 3. UI — ListSettingsModal
|
||||||
|
|
||||||
|
New files:
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` + `.axaml.cs`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
|
||||||
|
- **Right-click** on a list row in `ListsIslandView` → `ContextMenu` with "Settings…" item
|
||||||
|
- **Gear button** on the list row (visible on hover/selected)
|
||||||
|
|
||||||
|
Layout: vertical stack with two grouped sections.
|
||||||
|
|
||||||
|
**General**
|
||||||
|
- `Name` — TextBox (required, non-empty)
|
||||||
|
- `Working directory` — TextBox + "Browse…" button (folder picker)
|
||||||
|
- `Default commit type` — ComboBox populated with `chore, feat, fix, refactor, docs, test, ci, perf, style, build`
|
||||||
|
|
||||||
|
**Agent**
|
||||||
|
- `Model` — ComboBox: `(default)`, `sonnet`, `opus`, `haiku` (selecting `(default)` sends `null`)
|
||||||
|
- `System prompt` — multi-line TextBox with 4-row min height; empty = `null`
|
||||||
|
- `Agent file` — ComboBox populated via `WorkerClient.GetAgentsAsync()`, first item `(none)`; tooltip shows each agent's `Description`. Empty selection = `null`.
|
||||||
|
- `Reset agent settings` button — clears Model/SystemPrompt/AgentPath in the form (save then sends null triple → backend `DeleteAsync`).
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
- `SaveCommand` — validates, calls `UpdateList` then `UpdateListConfig`, closes modal on success.
|
||||||
|
- `CancelCommand` — closes without saving.
|
||||||
|
|
||||||
|
Loading: on open, ViewModel calls `GetListConfig` and populates fields; missing row means all three agent fields start empty.
|
||||||
|
|
||||||
|
ViewModel uses `[ObservableProperty]` / `[RelayCommand]` per project convention.
|
||||||
|
|
||||||
|
### 4. UI — DetailsIsland per-task agent section
|
||||||
|
|
||||||
|
Modify `DetailsIslandView.axaml` + `DetailsIslandViewModel.cs`.
|
||||||
|
|
||||||
|
Add an `Expander` titled **"Agent settings (overrides)"**, collapsed by default, below the existing task detail content.
|
||||||
|
|
||||||
|
Fields (same control types as ListSettingsModal's Agent section):
|
||||||
|
|
||||||
|
- `Model` — ComboBox prepended with `(inherit: <effective>)` option as the unset state
|
||||||
|
- `System prompt` — TextBox with watermark showing effective inherited value when empty
|
||||||
|
- `Agent file` — ComboBox prepended with `(inherit: <effective>)`
|
||||||
|
|
||||||
|
Effective-value computation:
|
||||||
|
|
||||||
|
- Server-side would be more accurate but requires a new hub call. For v1, UI computes locally: if task field is null, show the list's config value; if that's also null, show `(global default)`.
|
||||||
|
- `DetailsIslandViewModel` already has access to the selected `TaskDto` + list; add list-config loading when task selection changes.
|
||||||
|
|
||||||
|
Persistence: auto-save on field change (debounced 300ms) calling `UpdateTaskAgentSettings`. No separate Save button — matches "settings" feel.
|
||||||
|
|
||||||
|
If the task is currently `Running`, fields are **read-only** (disabling controls). Agent settings only apply to the next invocation.
|
||||||
|
|
||||||
|
### 5. Testing
|
||||||
|
|
||||||
|
xUnit integration tests in `tests/ClaudeDo.Worker.Tests` against a real SQLite temp DB:
|
||||||
|
|
||||||
|
- `ListConfigRepositoryTests`
|
||||||
|
- `UpsertAsync_InsertsWhenAbsent`
|
||||||
|
- `UpsertAsync_UpdatesWhenPresent`
|
||||||
|
- `DeleteAsync_RemovesRow`
|
||||||
|
- `GetByListIdAsync_ReturnsNullWhenAbsent`
|
||||||
|
- `ListRepositoryTests.UpdateAsync_UpdatesMutableFields`
|
||||||
|
- `TaskRepositoryTests.UpdateAgentSettingsAsync_NullsClearColumns`
|
||||||
|
- `WorkerHubTests` (if present pattern; otherwise via direct service call):
|
||||||
|
- `UpdateListConfig_AllNull_DeletesRow`
|
||||||
|
- `UpdateTaskAgentSettings_PersistsAndBroadcasts`
|
||||||
|
|
||||||
|
No UI tests — project has no UI test project. Build-time compile check is the only UI gate.
|
||||||
|
|
||||||
|
## Manual verification checklist
|
||||||
|
|
||||||
|
1. Open app, right-click a list → "Settings…" opens modal with correct current values.
|
||||||
|
2. Change model to `opus`, save, reopen → model persists.
|
||||||
|
3. Set system prompt on list, create task in list, run it → log confirms `--append-system-prompt` was passed.
|
||||||
|
4. Select task, set per-task Model = `haiku`, run → log confirms `--model haiku` overrides list value.
|
||||||
|
5. Unset per-task Model → effective falls back to list's model.
|
||||||
|
6. Click "Reset agent settings" on list → row removed, tasks fall back to global defaults.
|
||||||
|
7. Running task: DetailsIsland agent fields disabled.
|
||||||
|
|
||||||
|
## Risks / open questions
|
||||||
|
|
||||||
|
- **Refresh propagation**: `ListUpdated` is a new event; `IslandsShellViewModel` must subscribe and re-fetch. Any missed subscriber means stale UI. Mitigated by following the existing `TaskUpdated` pattern exactly.
|
||||||
|
- **Working-dir browser**: Avalonia folder picker API needs a `TopLevel`; pass via `StorageProvider`. Standard pattern in Avalonia 12.
|
||||||
|
- **Conventional-commit-type list**: hardcoded in ComboBox — acceptable, matches existing `CommitType` defaults.
|
||||||
208
docs/superpowers/specs/2026-04-22-worktree-merge-design.md
Normal file
208
docs/superpowers/specs/2026-04-22-worktree-merge-design.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Worktree merge into target branch — design
|
||||||
|
|
||||||
|
Date: 2026-04-22
|
||||||
|
Status: Approved (pending user review)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`WorktreeState.Merged` exists but nothing sets it. `GitService.MergeFfOnlyAsync` exists but is unused. `DetailsIslandViewModel.ApproveMergeAsync` is a stub (`// TODO: call worker merge hub method when available`). Users have no way to merge a task's worktree back into a target branch; the only post-task options today are Discard (via Reset) or leave it Active.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Allow merging a task worktree's `claudedo/{id}` branch into a chosen local branch of the list's `WorkingDir`.
|
||||||
|
- Preserve merge history via a real merge commit.
|
||||||
|
- Never leave the target branch in a broken state.
|
||||||
|
- Reuse existing patterns: `TaskResetService`, maintenance sweeper, dialog factory.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Remote push after merge (user does this manually).
|
||||||
|
- Pull/fetch before merge.
|
||||||
|
- Rebasing the task branch onto a moved target (done via Continue prompt or manually).
|
||||||
|
- Merging across repos or handling submodules.
|
||||||
|
- Automated UI tests (project has none).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
| --- | --- |
|
||||||
|
| Target branch | Default to current `HEAD` branch of `WorkingDir`; user may override via dropdown. |
|
||||||
|
| Merge strategy | Always `git merge --no-ff -m <msg> claudedo/{id}` — explicit merge commit. |
|
||||||
|
| Post-merge cleanup | Per-merge checkbox in the dialog, default on: remove worktree dir + delete branch. |
|
||||||
|
| Conflicts | Pre-flight guard (worktree/branch state checks); on conflict during merge, `git merge --abort` and return conflicted files to UI. |
|
||||||
|
| UI entry points | Details island agent strip (wires existing stub) **and** DiffModal "Merge" button — both open the same modal. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New backend service
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Services/TaskMergeService.cs` — mirrors `TaskResetService`.
|
||||||
|
|
||||||
|
```
|
||||||
|
public sealed class TaskMergeService
|
||||||
|
{
|
||||||
|
Task<MergeResult> MergeAsync(
|
||||||
|
string taskId,
|
||||||
|
string targetBranch,
|
||||||
|
bool removeWorktree,
|
||||||
|
string commitMessage,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MergeResult(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||||
|
// Status ∈ "merged" | "conflict" | "blocked"
|
||||||
|
|
||||||
|
public sealed record MergeTargets(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-flight checks (all must pass):
|
||||||
|
1. Task exists, status not `Running`.
|
||||||
|
2. Worktree exists, state == `Active`.
|
||||||
|
3. `list.WorkingDir` is set and is a git repo.
|
||||||
|
4. Target working tree is clean (`HasChangesAsync == false`).
|
||||||
|
5. Target repo is not mid-merge (`IsMidMergeAsync == false`).
|
||||||
|
|
||||||
|
Failures short-circuit to `MergeResult("blocked", [], reason)` before any git write.
|
||||||
|
|
||||||
|
Success path:
|
||||||
|
1. `GitService.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct)`.
|
||||||
|
2. If `removeWorktree`:
|
||||||
|
- `WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct)` — reuse existing method.
|
||||||
|
- `BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct)`.
|
||||||
|
3. `WorktreeRepository.SetStateAsync(taskId, WorktreeState.Merged, ct)`.
|
||||||
|
4. `HubBroadcaster.BroadcastWorktreeUpdated(taskId)`.
|
||||||
|
5. Log info; return `MergeResult("merged", [], null)`.
|
||||||
|
|
||||||
|
Conflict path (merge invoked, git returns non-zero with `CONFLICT` on stderr/stdout):
|
||||||
|
1. Collect conflicted files: `git diff --name-only --diff-filter=U`.
|
||||||
|
2. `GitService.MergeAbortAsync(list.WorkingDir, ct)`.
|
||||||
|
3. Worktree state stays `Active`; no broadcast (nothing changed).
|
||||||
|
4. Return `MergeResult("conflict", files, null)`.
|
||||||
|
|
||||||
|
### GitService additions
|
||||||
|
|
||||||
|
```
|
||||||
|
Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct); // git symbolic-ref --short HEAD
|
||||||
|
Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct); // git branch --format=%(refname:short)
|
||||||
|
Task MergeNoFfAsync(string repoDir, string sourceBranch, string message, CancellationToken ct); // git merge --no-ff -m <msg> <src>
|
||||||
|
Task MergeAbortAsync(string repoDir, CancellationToken ct); // git merge --abort
|
||||||
|
Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct); // File.Exists($"{gitDir}/MERGE_HEAD")
|
||||||
|
Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct); // git diff --name-only --diff-filter=U
|
||||||
|
```
|
||||||
|
|
||||||
|
`MergeNoFfAsync` must NOT throw on non-zero — it must return the exit code/stderr so the caller can distinguish conflict from other failures. Two ways:
|
||||||
|
- Overload to return `(int ExitCode, string Stderr)`; or
|
||||||
|
- Throw a dedicated `GitMergeConflictException` vs `InvalidOperationException`.
|
||||||
|
|
||||||
|
**Pick:** expose a tuple-returning variant for `MergeNoFfAsync` only — keeps other methods consistent, avoids exception-for-control-flow.
|
||||||
|
|
||||||
|
### Hub surface
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Hub/WorkerHub.cs` gains:
|
||||||
|
|
||||||
|
```
|
||||||
|
Task<MergeResultDto> MergeTask(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||||
|
Task<MergeTargetsDto> GetMergeTargets(string taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
DTOs mirror the service records. Unexpected exceptions are re-wrapped as `HubException` (same pattern as `ResetTask`). Expected conditions (blocked, conflict) travel via the result DTO, not exceptions.
|
||||||
|
|
||||||
|
### WorkerClient
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Services/WorkerClient.cs`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||||
|
Task<MergeTargetsDto> GetMergeTargetsAsync(string taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
**New modal:** `src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml` + `MergeModalViewModel.cs`.
|
||||||
|
|
||||||
|
Dialog fields:
|
||||||
|
- **Target branch** combobox (source: `GetMergeTargetsAsync.LocalBranches`; default: `DefaultBranch`).
|
||||||
|
- **Remove worktree after merge** checkbox (default: checked).
|
||||||
|
- **Commit message** text field (default: `Merge task: {Task.Title}`).
|
||||||
|
- OK / Cancel buttons.
|
||||||
|
|
||||||
|
Post-submit UI states (rendered inside the modal, not a second dialog):
|
||||||
|
- `merged` → brief success line, modal closes after 1–2s; parent refreshes.
|
||||||
|
- `conflict` → red inline panel listing files; OK button hidden, only Close remains.
|
||||||
|
- `blocked` → orange inline panel with the reason; OK button hidden, only Close remains.
|
||||||
|
|
||||||
|
**Wiring:**
|
||||||
|
- `DetailsIslandViewModel.ApproveMergeAsync` opens `MergeModalView` (factory injected through `MainWindowViewModel`'s existing dialog pattern).
|
||||||
|
- `DiffModalView` gains a Merge button in its command strip; click opens the same modal with the current task's id.
|
||||||
|
- Both entry points are only visible/enabled when `Task.Worktree?.State == Active` (same predicate as the existing Reset/Continue visibility logic — extend `ShowFailedActions`-style gating with a new flag `CanMerge`).
|
||||||
|
|
||||||
|
`MergeModalViewModel` depends only on `WorkerClient`. It does not touch `GitService` directly — all git access stays worker-side.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks Merge (Details island or DiffModal)
|
||||||
|
→ DetailsIslandViewModel / DiffModalViewModel opens MergeModalView
|
||||||
|
→ MergeModalViewModel.InitializeAsync
|
||||||
|
→ WorkerClient.GetMergeTargetsAsync(taskId)
|
||||||
|
→ Hub.GetMergeTargets
|
||||||
|
→ TaskMergeService.GetTargetsAsync
|
||||||
|
→ GitService.GetCurrentBranchAsync + ListLocalBranchesAsync
|
||||||
|
→ Combobox populated, default selected
|
||||||
|
User edits fields, clicks OK
|
||||||
|
→ WorkerClient.MergeTaskAsync(taskId, branch, remove, msg)
|
||||||
|
→ Hub.MergeTask
|
||||||
|
→ TaskMergeService.MergeAsync
|
||||||
|
→ pre-flight checks
|
||||||
|
→ GitService.MergeNoFfAsync → (success | conflict)
|
||||||
|
→ on success: optional remove + branch delete, SetState(Merged), broadcast
|
||||||
|
→ on conflict: MergeAbortAsync, return conflict DTO
|
||||||
|
→ MergeModalViewModel renders result
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Surfaced as |
|
||||||
|
| --- | --- |
|
||||||
|
| Task running | `MergeResult("blocked", [], "task is running")` |
|
||||||
|
| Worktree not Active | `("blocked", [], "worktree state is {state}")` |
|
||||||
|
| Working dir dirty | `("blocked", [], "target branch has uncommitted changes")` |
|
||||||
|
| Target mid-merge | `("blocked", [], "target branch is mid-merge")` |
|
||||||
|
| `list.WorkingDir` null | `("blocked", [], "list has no working directory")` |
|
||||||
|
| Merge conflict | `("conflict", [files], null)` — target auto-restored |
|
||||||
|
| Unknown git failure | `HubException` with stderr |
|
||||||
|
| Post-merge cleanup fails | Log a warning; merge already succeeded, state already `Merged`. Return `("merged", [], "cleanup: {reason}")` — `Status=="merged"` with a non-null `ErrorMessage` means the merge went through but the worktree couldn't be removed. UI surfaces this as a yellow note, not a failure. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Worker.Tests/TaskMergeServiceTests.cs` (real SQLite + real git, matching existing test conventions):
|
||||||
|
|
||||||
|
1. Happy path, ff-able history → one merge commit, state Merged, broadcast fired.
|
||||||
|
2. Happy path, diverged non-conflicting → merge commit created.
|
||||||
|
3. Conflict path → conflicted files returned, target branch HEAD matches pre-merge, `MERGE_HEAD` absent, worktree state still Active.
|
||||||
|
4. Pre-flight: worktree Merged/Discarded → blocked.
|
||||||
|
5. Pre-flight: dirty working tree → blocked.
|
||||||
|
6. Pre-flight: mid-merge target → blocked.
|
||||||
|
7. `removeWorktree=true` → worktree dir gone, branch deleted, state Merged.
|
||||||
|
8. `removeWorktree=false` → worktree + branch survive, state Merged.
|
||||||
|
9. Task Running → blocked.
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Worker.Tests/GitServiceMergeTests.cs` (narrow tests for new GitService methods): `MergeNoFfAsync` success/conflict tuple semantics, `MergeAbortAsync` clears MERGE_HEAD, `IsMidMergeAsync` true/false, `ListLocalBranchesAsync` returns expected set, `GetCurrentBranchAsync` on fresh repo.
|
||||||
|
|
||||||
|
Manual UI checklist captured in the implementation plan, not automated.
|
||||||
|
|
||||||
|
## Implementation order (sketch)
|
||||||
|
|
||||||
|
1. GitService additions + their tests.
|
||||||
|
2. `TaskMergeService` + its tests (hub/UI not yet wired).
|
||||||
|
3. Hub methods + `WorkerClient` methods.
|
||||||
|
4. `MergeModalView` + `MergeModalViewModel`.
|
||||||
|
5. Wire `DetailsIslandViewModel.ApproveMergeAsync`.
|
||||||
|
6. Wire DiffModal Merge button.
|
||||||
|
7. Manual UI walkthrough against the checklist.
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
|
||||||
|
None.
|
||||||
177
docs/superpowers/specs/2026-04-23-default-agents-design.md
Normal file
177
docs/superpowers/specs/2026-04-23-default-agents-design.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Default Agents — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ship ClaudeDo with a curated set of default agents so that users have useful agents available on first launch, without losing the file-based ownership model (user-editable, user-deletable). Provide a "Restore defaults" action to recover missing defaults on demand.
|
||||||
|
|
||||||
|
## Agents to Ship
|
||||||
|
|
||||||
|
Six markdown agents covering the common stages of task execution plus one general-purpose agent:
|
||||||
|
|
||||||
|
| File | Focus |
|
||||||
|
|---|---|
|
||||||
|
| `code-reviewer.md` | Review diff for bugs, logic errors, convention adherence. Flags only high-confidence issues. |
|
||||||
|
| `test-writer.md` | Generate unit/integration tests for changed code. Follows existing test patterns. |
|
||||||
|
| `debugger.md` | Systematic root-cause analysis — reproduce, isolate, hypothesize, verify. |
|
||||||
|
| `security-reviewer.md` | OWASP-style audit focused on auth, SQL injection, input handling, secret exposure. |
|
||||||
|
| `explorer.md` | Fast codebase navigation and answering "where/how" questions. Terse output. |
|
||||||
|
| `researcher.md` | General-purpose research, doc summarization, analysis, investigation. Non-code. |
|
||||||
|
|
||||||
|
Each file uses Claude Code's standard agent frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: <agent name>
|
||||||
|
description: <one-line description>
|
||||||
|
---
|
||||||
|
|
||||||
|
<system prompt body>
|
||||||
|
```
|
||||||
|
|
||||||
|
Content target: ~20–40 lines per file. Style matches the existing Claude Code agent conventions.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
**Seed on first launch, restore on demand:**
|
||||||
|
|
||||||
|
1. Bundled agents live alongside the Worker binary at `<AppContext.BaseDirectory>/DefaultAgents/*.md`.
|
||||||
|
2. At Worker startup, for each bundled file: if `~/.todo-app/agents/<name>.md` does NOT exist, copy it in. If it exists, leave it alone — the user owns their copy.
|
||||||
|
3. A "Restore default agents" button in the settings modal re-runs the same check, restoring any that the user has deleted.
|
||||||
|
|
||||||
|
The seed path and the restore path are the same code — only the invocation differs (startup vs. hub call).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `DefaultAgents/*.md` (bundled content)
|
||||||
|
|
||||||
|
Location: `src/ClaudeDo.Worker/DefaultAgents/`
|
||||||
|
Packaging: `<Content Include="DefaultAgents\*.md" CopyToOutputDirectory="PreserveNewest" />` in `ClaudeDo.Worker.csproj`.
|
||||||
|
At runtime they land at `<AppContext.BaseDirectory>/DefaultAgents/*.md` next to the executable.
|
||||||
|
|
||||||
|
### `DefaultAgentSeeder` (new service)
|
||||||
|
|
||||||
|
Location: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||||
|
|
||||||
|
```
|
||||||
|
public sealed class DefaultAgentSeeder
|
||||||
|
{
|
||||||
|
public DefaultAgentSeeder(string bundleDir, string targetDir);
|
||||||
|
public Task<SeedResult> SeedMissingAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SeedResult(int Copied, int Skipped);
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior of `SeedMissingAsync`:
|
||||||
|
- If `bundleDir` doesn't exist, log warning and return `(0, 0)`.
|
||||||
|
- Enumerate `*.md` in `bundleDir`.
|
||||||
|
- For each file, if the target path (`targetDir/<filename>`) is missing, copy it; else increment `Skipped`.
|
||||||
|
- Create `targetDir` if missing (consistent with existing `AgentFileService.WriteAsync`).
|
||||||
|
- Per-file exceptions are caught and logged; the seeder continues with the next file. The method itself does not throw for individual file failures.
|
||||||
|
|
||||||
|
### `Program.cs` wiring
|
||||||
|
|
||||||
|
After `AgentFileService` registration and before `app.Run()`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var bundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
||||||
|
var seeder = new DefaultAgentSeeder(bundleDir, agentsDir);
|
||||||
|
await seeder.SeedMissingAsync();
|
||||||
|
builder.Services.AddSingleton(seeder);
|
||||||
|
```
|
||||||
|
|
||||||
|
The seeder is also registered as a singleton so the hub can invoke it for the restore flow.
|
||||||
|
|
||||||
|
### `WorkerHub.RestoreDefaultAgents`
|
||||||
|
|
||||||
|
Location: add method to existing `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
|
||||||
|
|
||||||
|
Signature: `public async Task<SeedResult> RestoreDefaultAgents()`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Calls `DefaultAgentSeeder.SeedMissingAsync()`.
|
||||||
|
- Returns the `SeedResult` to the caller.
|
||||||
|
- No separate broadcast — the UI will call `GetAgents` after the restore returns, reusing the existing refresh path.
|
||||||
|
|
||||||
|
### UI — Settings Modal
|
||||||
|
|
||||||
|
Location: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` + `SettingsModalViewModel`.
|
||||||
|
|
||||||
|
Add a "Restore default agents" button to the modal. On click:
|
||||||
|
1. Disable the button, show a spinner label.
|
||||||
|
2. Call `WorkerClient.RestoreDefaultAgentsAsync()`.
|
||||||
|
3. Show a brief inline confirmation: `"Restored {Copied} agent(s)"` or `"All defaults already present"`.
|
||||||
|
4. Trigger the existing agent list refresh so the new files appear immediately in the rest of the UI.
|
||||||
|
|
||||||
|
### `WorkerClient` method
|
||||||
|
|
||||||
|
Add `Task<SeedResult> RestoreDefaultAgentsAsync(CancellationToken ct = default)` to `WorkerClient` — thin wrapper that invokes the hub method.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
**Startup:**
|
||||||
|
```
|
||||||
|
Worker starts → DefaultAgentSeeder.SeedMissingAsync()
|
||||||
|
→ copies missing files into ~/.todo-app/agents/
|
||||||
|
AgentFileService.ScanAsync() (on first GetAgents call) → sees the seeded files
|
||||||
|
```
|
||||||
|
|
||||||
|
**User restores:**
|
||||||
|
```
|
||||||
|
Settings modal button click
|
||||||
|
→ WorkerClient.RestoreDefaultAgentsAsync()
|
||||||
|
→ WorkerHub.RestoreDefaultAgents()
|
||||||
|
→ DefaultAgentSeeder.SeedMissingAsync()
|
||||||
|
→ returns SeedResult(copied, skipped)
|
||||||
|
UI shows confirmation, triggers GetAgents refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Missing `DefaultAgents/` bundle dir | Log warning, return `(0, 0)`. Startup proceeds. |
|
||||||
|
| Individual file copy failure (disk, permissions) | Catch per-file, log, continue with the remaining files. |
|
||||||
|
| Corrupt bundled markdown (no valid frontmatter) | Copied anyway — the `AgentFileService` frontmatter parser already falls back to filename-as-name. |
|
||||||
|
| Startup seeder exception (unexpected) | Log as warning, do not crash the Worker. Agents can still be restored via the button. |
|
||||||
|
| Hub `RestoreDefaultAgents` exception | Propagate to client as SignalR error; UI shows a generic "Restore failed" message. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Unit:** `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||||
|
|
||||||
|
- Seeds all files when target dir is empty.
|
||||||
|
- Skips files that already exist.
|
||||||
|
- Preserves existing user-modified files (file mtime / content unchanged).
|
||||||
|
- Returns accurate `SeedResult` counts.
|
||||||
|
- Handles missing bundle dir gracefully (returns `(0, 0)`, no throw).
|
||||||
|
- Creates target dir if it doesn't exist.
|
||||||
|
|
||||||
|
**Integration:** extend `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
|
||||||
|
|
||||||
|
- `RestoreDefaultAgents` invokes the seeder and returns the count.
|
||||||
|
|
||||||
|
**No UI tests.** The project has no UI test harness; settings modal behavior is exercised manually.
|
||||||
|
|
||||||
|
## Build / Packaging
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` gains:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="DefaultAgents\*.md">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
No new NuGet dependencies.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Editing bundled agents in-place (user edits their copy under `~/.todo-app/agents/`; bundle is read-only by convention).
|
||||||
|
- Versioning / updating user copies when the bundled version changes. If a bundled agent is improved in a later release, the user's copy is not overwritten. A future release may add a "diff / reset to bundled" flow, but not now.
|
||||||
|
- Packaging as embedded resources. Content files copied to output are simpler, inspectable on disk, and consistent with the file-based agent model.
|
||||||
468
docs/superpowers/specs/2026-04-23-planning-sessions-design.md
Normal file
468
docs/superpowers/specs/2026-04-23-planning-sessions-design.md
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
# Planning Sessions — Design
|
||||||
|
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Scope:** Feature — "Open planning Session" context menu on tasks that spawns an interactive Windows Terminal with Claude (Sonnet 4.6, medium thinking) and a scoped MCP server, letting the user brainstorm and have Claude break a rough task into concrete executable child-tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Allow a user to take a vague task (a title plus some TODO-style notes) and convert it — via interactive dialogue with Claude in a terminal — into a structured set of concrete, executable child-tasks that the worker queue can pick up and run.
|
||||||
|
|
||||||
|
The interaction is driven by Claude calling MCP tools against a scoped server running inside the existing `ClaudeDo.Worker` process. The parent task becomes a "Planning" container that holds its children as a flat (single-level) hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Status Flow
|
||||||
|
|
||||||
|
**Parent (new statuses `Planning`, `Planned`):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Manual ──[Open planning Session]──▶ Planning ──[finalize]──▶ Planned
|
||||||
|
│ │
|
||||||
|
│ (all children reach terminal state)
|
||||||
|
│ ▼
|
||||||
|
│ Done
|
||||||
|
│ or
|
||||||
|
│ Failed (if any child Failed)
|
||||||
|
▼
|
||||||
|
[Discard] ──▶ Manual
|
||||||
|
```
|
||||||
|
|
||||||
|
**Child (new status `Draft`):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Draft ──[finalize]──▶ Manual | Queued (if "agent" tag) ──▶ Running ──▶ Done | Failed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Parent with status `Planning` or `Planned` is **never** picked up by the queue.
|
||||||
|
- Children with status `Draft` are **never** picked up by the queue.
|
||||||
|
- Hierarchy is strictly **one level deep**: a child task cannot itself become a planning parent (enforced app-side: Plan menu item hidden/disabled if `ParentTaskId IS NOT NULL`).
|
||||||
|
- One planning session per parent task at a time (`StartPlanningSessionAsync` errors if parent is already `Planning`; use Resume instead).
|
||||||
|
- Parent auto-status on child completion (evaluated after any child reaches `Done` or `Failed`):
|
||||||
|
- At least one child `Failed` and no children still in non-terminal states → Parent `Failed`.
|
||||||
|
- All children `Done` → Parent `Done`.
|
||||||
|
- Any child still `Manual`/`Queued`/`Running`/`Draft` → Parent stays `Planned`.
|
||||||
|
- Worktree state (`Merged`/`Discarded`/`Kept`) is orthogonal; only `Task.Status` determines completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Model
|
||||||
|
|
||||||
|
### 3.1 Schema changes to `Tasks` table
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ParentTaskId` | `string` (FK → `Tasks.Id`, `DeleteBehavior.Restrict`) | yes | When set, row is a child of a planning parent. NULL = top-level task. |
|
||||||
|
| `PlanningSessionId` | `string` | yes | Claude CLI session ID captured after first run; used with `--resume`. Only set on planning parents. |
|
||||||
|
| `PlanningSessionToken` | `string` | yes | Random 32-byte Base64 token generated per session; acts as bearer for MCP calls. NULL when no active session. |
|
||||||
|
| `PlanningFinalizedAt` | `DateTime` | yes | Timestamp when `finalize` was called. NULL until finalized. |
|
||||||
|
|
||||||
|
Index: `(ParentTaskId)` for fast children lookup.
|
||||||
|
|
||||||
|
### 3.2 Status enum additions
|
||||||
|
|
||||||
|
`ClaudeDo.Data.Models.TaskStatus` gains:
|
||||||
|
- `Planning` — parent, session active or paused, drafts may exist.
|
||||||
|
- `Planned` — parent, finalized, children are real tasks (may still be running).
|
||||||
|
- `Draft` — child, created during session, not yet finalized.
|
||||||
|
|
||||||
|
Existing values unchanged: `Manual | Queued | Running | Done | Failed`. Persisted via `ValueConverter` to string (existing convention — confirmed via `TaskEntity.cs`).
|
||||||
|
|
||||||
|
### 3.3 Navigation properties
|
||||||
|
|
||||||
|
On `TaskEntity`:
|
||||||
|
```csharp
|
||||||
|
public string? ParentTaskId { get; set; }
|
||||||
|
public TaskEntity? Parent { get; set; }
|
||||||
|
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
||||||
|
public string? PlanningSessionId { get; set; }
|
||||||
|
public string? PlanningSessionToken { get; set; }
|
||||||
|
public DateTime? PlanningFinalizedAt { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
In `TaskEntityConfiguration`:
|
||||||
|
```csharp
|
||||||
|
.HasOne(t => t.Parent)
|
||||||
|
.WithMany(t => t.Children)
|
||||||
|
.HasForeignKey(t => t.ParentTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale for `Restrict`:** cascade delete would orphan worktrees of in-flight child tasks. UI must handle the `DbUpdateException` and prompt the user to discard children first.
|
||||||
|
|
||||||
|
### 3.4 Repository additions
|
||||||
|
|
||||||
|
`ITaskRepository` gains:
|
||||||
|
- `Task<IReadOnlyList<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct)`
|
||||||
|
- `Task<TaskEntity> CreateChildAsync(string parentId, string title, string? description, IReadOnlyList<string>? tagNames, string? commitType, CancellationToken ct)` — creates with `Status = Draft`, `ParentTaskId = parentId`.
|
||||||
|
- `Task<int> FinalizePlanningAsync(string parentId, bool queueAgentTasks, CancellationToken ct)` — transactional: all Drafts → `Manual` (or `Queued` if tagged "agent" and `queueAgentTasks=true`), parent → `Planned`, set `PlanningFinalizedAt`, clear `PlanningSessionToken`. Returns count of finalized children.
|
||||||
|
- `Task<bool> DiscardPlanningAsync(string parentId, CancellationToken ct)` — deletes all Drafts, parent → `Manual`, clears `PlanningSessionId/Token/FinalizedAt`.
|
||||||
|
- `Task<TaskEntity?> SetPlanningStartedAsync(string taskId, string sessionToken, CancellationToken ct)` — sets parent `Status = Planning`, stores token; returns null if parent not in `Manual` state.
|
||||||
|
- `Task UpdatePlanningSessionIdAsync(string parentId, string sessionId, CancellationToken ct)` — captures Claude CLI session ID after launch.
|
||||||
|
- `Task<TaskEntity?> FindByPlanningTokenAsync(string token, CancellationToken ct)` — used by MCP auth handler.
|
||||||
|
|
||||||
|
`GetNextQueuedAgentTaskAsync` — verify the existing query filters on `Status = Queued`; no additional filter needed since Planning/Planned/Draft are different statuses. Add explicit regression test.
|
||||||
|
|
||||||
|
### 3.5 Auto-status hook
|
||||||
|
|
||||||
|
After every `MarkDoneAsync`/`MarkFailedAsync` on a task with `ParentTaskId != null`, check parent children. If all in terminal state:
|
||||||
|
- Any `Failed` → parent `Failed` with `FinishedAt = now()`.
|
||||||
|
- All `Done` (or worktrees `Discarded`) → parent `Done` with `FinishedAt = now()`.
|
||||||
|
|
||||||
|
Implemented as a private helper `TryCompleteParentAsync(string parentId, CancellationToken ct)` called at the end of the two Mark methods.
|
||||||
|
|
||||||
|
### 3.6 Migration
|
||||||
|
|
||||||
|
`dotnet ef migrations add AddPlanningSupport` — adds four columns and the `(ParentTaskId)` index. No data migration needed (new columns all nullable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MCP Server Surface
|
||||||
|
|
||||||
|
### 4.1 Transport
|
||||||
|
|
||||||
|
**HTTP (streamable) inside the existing Worker Kestrel host.** Mount on `/mcp` alongside the existing SignalR hub at `127.0.0.1:47821`. No separate process, no stdio proxy.
|
||||||
|
|
||||||
|
Library: `ModelContextProtocol` (official C# MCP SDK).
|
||||||
|
|
||||||
|
### 4.2 Authentication
|
||||||
|
|
||||||
|
Per-session bearer token:
|
||||||
|
1. `StartPlanningSessionAsync` generates a 32-byte random token, persists to `Tasks.PlanningSessionToken`.
|
||||||
|
2. Token is written into the session's `mcp.json` as `Authorization: Bearer <token>`.
|
||||||
|
3. Every MCP request passes through an auth filter that looks up the token via `FindByPlanningTokenAsync`. If found, the parent task ID is stored in the request context. If not, 401.
|
||||||
|
4. Token is invalidated (set NULL) on `finalize` or `discard`.
|
||||||
|
|
||||||
|
### 4.3 Tools
|
||||||
|
|
||||||
|
All tools are scoped to the parent task resolved from the request's token. `parent_id` is never an argument.
|
||||||
|
|
||||||
|
| Tool | Params | Returns | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `create_child_task` | `title: string`, `description?: string`, `tags?: string[]`, `commit_type?: string` | `{ task_id, status: "Draft" }` | Creates a Draft child under this session's parent. |
|
||||||
|
| `list_child_tasks` | — | `[{ task_id, title, description, status, tags }]` | Lists children of this parent (in session context, always Drafts). |
|
||||||
|
| `update_child_task` | `task_id: string`, optional: `title`, `description`, `tags`, `commit_type` | `{ task }` | Errors if target is not a Draft or not a child of this parent. |
|
||||||
|
| `delete_child_task` | `task_id: string` | `{ ok: true }` | Errors if target is not a Draft or not a child of this parent. |
|
||||||
|
| `update_planning_task` | `title?: string`, `description?: string` | `{ task }` | Only title/description on the parent itself. |
|
||||||
|
| `finalize` | `queue_agent_tasks?: bool = true` | `{ finalized_count: int }` | Calls `FinalizePlanningAsync`. Token invalidated. |
|
||||||
|
|
||||||
|
### 4.4 Real-time UI
|
||||||
|
|
||||||
|
After each successful tool call, the MCP handler fires a `TaskUpdated` event on the Worker's SignalR hub. The UI subscribes as it already does; drafts appear/update live in the tasks list while the user chats with Claude in the terminal.
|
||||||
|
|
||||||
|
### 4.5 Errors
|
||||||
|
|
||||||
|
- 401 for missing/invalid token.
|
||||||
|
- MCP error `-32602` "task not found or not a child of this planning session" for cross-parent access attempts.
|
||||||
|
- MCP error `-32602` "cannot modify finalized task" for `update/delete` on non-Draft.
|
||||||
|
- Token validation short-circuits before tool dispatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Terminal Launch & Claude CLI Invocation
|
||||||
|
|
||||||
|
### 5.1 Launcher service
|
||||||
|
|
||||||
|
New interface `IPlanningTerminalLauncher` in the UI or App layer:
|
||||||
|
```csharp
|
||||||
|
Task LaunchAsync(PlanningSessionStart info, CancellationToken ct);
|
||||||
|
Task LaunchResumeAsync(PlanningSessionResume info, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
`PlanningSessionStart` contains: `WorkingDir`, `McpConfigPath`, `InitialPromptPath`, `SystemPromptPath`.
|
||||||
|
`PlanningSessionResume` contains: `WorkingDir`, `McpConfigPath`, `ClaudeSessionId`.
|
||||||
|
|
||||||
|
### 5.2 Per-session files
|
||||||
|
|
||||||
|
Path: `~/.todo-app/planning-sessions/<parentTaskId>/`
|
||||||
|
- `mcp.json` — MCP config referencing the HTTP endpoint with bearer token.
|
||||||
|
- `system-prompt.md` — planning-mode system prompt (append, not replace).
|
||||||
|
- `initial-prompt.txt` — first user-visible message (title + description + short instructions).
|
||||||
|
|
||||||
|
Cleanup:
|
||||||
|
- `Discard` → remove directory.
|
||||||
|
- `Finalize` → keep directory (for audit; prune on app start if older than N days, optional).
|
||||||
|
|
||||||
|
### 5.3 `mcp.json` format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"claudedo": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:47821/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer <PlanningSessionToken>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Claude CLI invocation (new session)
|
||||||
|
|
||||||
|
```
|
||||||
|
wt.exe -d "<list.WorkingDir>" cmd /k ^
|
||||||
|
claude ^
|
||||||
|
--model claude-sonnet-4-6 ^
|
||||||
|
--append-system-prompt "<contents of system-prompt.md>" ^
|
||||||
|
--mcp-config "<mcp.json absolute path>" ^
|
||||||
|
--allowedTools "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill" ^
|
||||||
|
"<contents of initial-prompt.txt>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Claude CLI invocation (resume)
|
||||||
|
|
||||||
|
```
|
||||||
|
wt.exe -d "<list.WorkingDir>" cmd /k ^
|
||||||
|
claude --resume <PlanningSessionId> --mcp-config "<mcp.json>"
|
||||||
|
```
|
||||||
|
Resume inherits model, system prompt, and allowed tools from the original session.
|
||||||
|
|
||||||
|
### 5.6 System prompt (draft, refined in Plan B)
|
||||||
|
|
||||||
|
> You are in a ClaudeDo planning session for a task. Your job is to brainstorm with the user, then break their rough intent into concrete, independently-executable child-tasks. Each child-task should be something a single automated agent can pick up and complete autonomously. Use the `mcp__claudedo__*` tools to create/update/delete drafts in real time. You may read the repository for context (Read/Grep/Glob) but must not modify any files. When the user is satisfied, call `finalize`. Skills you may find useful: `superpowers:writing-plans`, `superpowers:writing-clearly-and-concisely`.
|
||||||
|
|
||||||
|
### 5.7 Initial prompt (template)
|
||||||
|
|
||||||
|
```
|
||||||
|
<Parent task title>
|
||||||
|
|
||||||
|
<Parent task description, if any>
|
||||||
|
|
||||||
|
---
|
||||||
|
We're planning this task together. Brainstorm with me, then create concrete child-tasks via the MCP tools. I'll call `finalize` when we're done.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.8 Unknowns to resolve during Plan B implementation
|
||||||
|
|
||||||
|
These are left **open** in this spec; they'll be pinned down during implementation via `mcp__plugin_context7_context7__query-docs` for the Claude Code CLI:
|
||||||
|
|
||||||
|
1. Exact flag for thinking budget (`--thinking-budget medium`? model suffix `claude-sonnet-4-6-thinking`? something else?).
|
||||||
|
2. Exact casing of tool names in `--allowedTools` (`Read`/`read`, `WebFetch`/`web_fetch`, `Skill`).
|
||||||
|
3. Whether `--append-system-prompt` accepts a file reference (`@path`) or requires inline string.
|
||||||
|
4. Whether Claude CLI supports a `--session-id` flag for pre-assigning the session ID, or whether we must read it back from `~/.claude/projects/<hash>/sessions/` after the process starts.
|
||||||
|
|
||||||
|
If (4) resolves to "read back", strategy:
|
||||||
|
- Poll `~/.claude/projects/<hash>/sessions/` directory modtimes shortly after launch; newest session file after launch timestamp is ours.
|
||||||
|
- Cache the result on the parent task via `UpdatePlanningSessionIdAsync`.
|
||||||
|
- If session ID can't be captured, Resume falls back to `claude --continue` (last session in that project).
|
||||||
|
|
||||||
|
### 5.9 Pre-flight checks
|
||||||
|
|
||||||
|
On `LaunchAsync`:
|
||||||
|
- `wt.exe` resolvable in PATH → else throw `PlanningLaunchException("Windows Terminal not found")`, UI shows install hint.
|
||||||
|
- `claude` resolvable in PATH → else `PlanningLaunchException("Claude CLI not installed")`.
|
||||||
|
- `list.WorkingDir` exists → else `PlanningLaunchException("Working directory not found: <path>")`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI Changes
|
||||||
|
|
||||||
|
### 6.1 Context menu (`TaskRowView.axaml`)
|
||||||
|
|
||||||
|
New entries, conditional on status:
|
||||||
|
- `Manual` + `ParentTaskId IS NULL` → **"Open planning Session"**
|
||||||
|
- `Planning` → **"Resume planning Session"** and **"Discard planning session"**
|
||||||
|
- `Planned` / `Done` / `Failed` (parent) → no planning-related entries (7c: no re-planning).
|
||||||
|
- Children (`ParentTaskId IS NOT NULL`) → never show planning entries.
|
||||||
|
|
||||||
|
### 6.2 Hierarchy rendering (`TasksIslandView.axaml`)
|
||||||
|
|
||||||
|
Approach: **flat stream with indentation**, not a `TreeView`.
|
||||||
|
|
||||||
|
- `TasksIslandViewModel` builds `OpenItems`/`CompletedItems`/etc. as flat `ObservableCollection<TaskRowViewModel>` with parents followed by their children if expanded.
|
||||||
|
- `TaskRowViewModel` gets `IsChild: bool` and `IsPlanningParent: bool` and `IsExpanded: bool`.
|
||||||
|
- `TaskRowView` indents 24px when `IsChild`, shows a thin left border in `TextFaintBrush`.
|
||||||
|
- Parents with `IsPlanningParent` render a chevron (▸/▾) that toggles `IsExpanded`; collapsed parents hide their children from the flat stream.
|
||||||
|
- Expanded-state map kept in the VM (`Dictionary<string, bool>`, default `true`).
|
||||||
|
|
||||||
|
### 6.3 Draft and planning styling (`TaskRowView`)
|
||||||
|
|
||||||
|
- `Status = Draft` → row italic, 70% opacity, small left-aligned badge "DRAFT".
|
||||||
|
- Parent `Status = Planning` → badge "PLANNING" (accent: warning-amber).
|
||||||
|
- Parent `Status = Planned` → badge "PLANNED" (accent: neutral-blue).
|
||||||
|
|
||||||
|
### 6.4 Unfinished-session dialog
|
||||||
|
|
||||||
|
Trigger: on app start **and** on any context-menu click against a `Planning` parent.
|
||||||
|
|
||||||
|
Modal (built with existing `TaskCompletionSource<T>` dialog pattern):
|
||||||
|
|
||||||
|
```
|
||||||
|
Unfinished planning session
|
||||||
|
"<Parent title>"
|
||||||
|
<N> draft tasks waiting to be finalized.
|
||||||
|
|
||||||
|
[Resume] [Finalize now] [Discard]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Resume → `ResumePlanningSessionAsync`, opens terminal with `--resume`.
|
||||||
|
- Finalize now → `FinalizePlanningSessionAsync` (server-side, no terminal). Useful when the user is confident drafts are good.
|
||||||
|
- Discard → `DiscardPlanningSessionAsync`.
|
||||||
|
|
||||||
|
### 6.5 TasksIslandViewModel commands
|
||||||
|
|
||||||
|
- `[RelayCommand] OpenPlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] ResumePlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] DiscardPlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] FinalizePlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] ToggleExpand(TaskRowViewModel parentRow)`
|
||||||
|
|
||||||
|
### 6.6 WorkerClient additions (`ClaudeDo.Ui/Services/WorkerClient.cs`)
|
||||||
|
|
||||||
|
- `Task<PlanningSessionLaunchInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, InitialPromptPath, SystemPromptPath }`.
|
||||||
|
- `Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, ClaudeSessionId }`.
|
||||||
|
- `Task<int> FinalizePlanningSessionAsync(string taskId, CancellationToken ct)` — returns finalized count.
|
||||||
|
- `Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct)`.
|
||||||
|
- `Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)` — for the unfinished-session dialog.
|
||||||
|
|
||||||
|
Existing `TaskUpdated` event covers live draft updates; no new event needed.
|
||||||
|
|
||||||
|
### 6.7 Delete handling
|
||||||
|
|
||||||
|
When the user tries to delete a parent with children:
|
||||||
|
- Repository throws `DbUpdateException` (FK Restrict).
|
||||||
|
- UI catches, shows: "This task has N child tasks. Discard drafts and delete? / Delete all including children? / Cancel."
|
||||||
|
- "Delete all including children" → UI iterates children and deletes them first, then the parent.
|
||||||
|
- "Discard drafts" option only appears if parent status is `Planning` (drafts exist to discard).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Lifecycle & Error Handling
|
||||||
|
|
||||||
|
### 7.1 Worker queue isolation
|
||||||
|
|
||||||
|
`GetNextQueuedAgentTaskAsync` filters on `Status = Queued` — Planning/Planned/Draft are excluded by status. Add explicit regression test to lock this in.
|
||||||
|
|
||||||
|
### 7.2 Parent auto-completion (repeat of 2, for implementation reference)
|
||||||
|
|
||||||
|
After `MarkDoneAsync`/`MarkFailedAsync`:
|
||||||
|
```csharp
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
await TryCompleteParentAsync(task.ParentTaskId, ct);
|
||||||
|
```
|
||||||
|
where `TryCompleteParentAsync` loads children, checks terminal status, sets parent accordingly.
|
||||||
|
|
||||||
|
### 7.3 Session-start errors
|
||||||
|
|
||||||
|
Table in §5.9 Pre-flight checks. UI receives typed exceptions, shows appropriate dialog.
|
||||||
|
|
||||||
|
### 7.4 Session-runtime errors
|
||||||
|
|
||||||
|
- Terminal crashes → drafts + token persist. Resume via dialog (§6.4).
|
||||||
|
- Worker restart → drafts + token persist. Resume rebuilds HTTP connection.
|
||||||
|
- MCP call fails transiently → Claude CLI retries or the model reports the error to the user in terminal; drafts remain in whatever state the last successful call left them.
|
||||||
|
- No session timeout — brainstorming may be long.
|
||||||
|
|
||||||
|
### 7.5 Concurrency
|
||||||
|
|
||||||
|
- Different parents → independent sessions, one token per parent.
|
||||||
|
- Same parent launched twice → `StartPlanningSessionAsync` throws; UI says "Already planning; use Resume".
|
||||||
|
- Cleanup on app exit: nothing — planning state is fully persisted in DB and files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
### 8.1 Automated (in `ClaudeDo.Worker.Tests`)
|
||||||
|
|
||||||
|
**Schema & repository:**
|
||||||
|
- Migration applies cleanly on fresh DB.
|
||||||
|
- `GetChildrenAsync` returns only direct children, sorted.
|
||||||
|
- `CreateChildAsync` sets Status=Draft, ParentTaskId correctly.
|
||||||
|
- `FinalizePlanningAsync` transactionally transitions drafts to Manual/Queued, sets parent to Planned, sets timestamp, clears token. On simulated DB error, rolls back fully.
|
||||||
|
- `DiscardPlanningAsync` removes drafts, resets parent.
|
||||||
|
- `GetNextQueuedAgentTaskAsync` ignores Drafts, Planning parents, Planned parents.
|
||||||
|
- `Restrict` cascade: delete parent with children throws `DbUpdateException`.
|
||||||
|
|
||||||
|
**Auto-status hook (§7.2):**
|
||||||
|
- All children Done → parent Done.
|
||||||
|
- Mix: some Done, at least one Failed, rest in terminal state → parent Failed.
|
||||||
|
- Mix with one still Running → parent stays Planned.
|
||||||
|
- Parent stays Planned while any Draft exists (defensive — finalize should have cleared them).
|
||||||
|
|
||||||
|
**MCP handlers (against SQLite + in-process HTTP):**
|
||||||
|
- Valid token → tool executes.
|
||||||
|
- Missing/invalid token → 401.
|
||||||
|
- `create_child_task` → creates Draft, emits TaskUpdated event.
|
||||||
|
- `update_child_task` on non-Draft → MCP error.
|
||||||
|
- `delete_child_task` on non-Draft → MCP error.
|
||||||
|
- `finalize` called twice: first succeeds, second errors because token is invalidated.
|
||||||
|
- Cross-parent access: tool with `task_id` belonging to another parent's session → MCP error.
|
||||||
|
|
||||||
|
**SignalR endpoints (integration with Worker host):**
|
||||||
|
- Start → token generated, session directory + files created, `mcp.json` contains token.
|
||||||
|
- Start on already-`Planning` parent → error.
|
||||||
|
- Resume → no new token, reads `PlanningSessionId` from DB.
|
||||||
|
- Discard → drafts gone, directory removed, token NULL, parent back to Manual.
|
||||||
|
|
||||||
|
### 8.2 Manual (added to `docs/open.md` checklist)
|
||||||
|
|
||||||
|
- Windows Terminal spawn with real `wt.exe`.
|
||||||
|
- Real Claude CLI end-to-end session (requires `ANTHROPIC_API_KEY`).
|
||||||
|
- Avalonia hierarchy rendering (chevron, indentation, draft styling, badges).
|
||||||
|
- Session-ID capture from `~/.claude/projects/...` (timing-sensitive, platform-specific).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phasing
|
||||||
|
|
||||||
|
Work is delivered in **three sequential-then-parallel** plans. Plan A must merge before B and C can merge.
|
||||||
|
|
||||||
|
### 9.1 Plan A — Foundation
|
||||||
|
|
||||||
|
Schema migration, enum additions, repository methods, auto-status hook, delete-Restrict, regression test for queue filter. No UI-visible changes (other than delete-with-children now failing with a generic error until Plan C handles it).
|
||||||
|
|
||||||
|
Scope files (approximate):
|
||||||
|
- `src/ClaudeDo.Data/Models/TaskEntity.cs`
|
||||||
|
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
|
||||||
|
- `src/ClaudeDo.Data/Migrations/<new>_AddPlanningSupport.cs`
|
||||||
|
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (+ `ITaskRepository`)
|
||||||
|
- `src/ClaudeDo.Worker/...` auto-status hook call-site updates.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/...` new test classes.
|
||||||
|
|
||||||
|
### 9.2 Plan B — Worker MCP + SignalR + Launcher (starts after A merges)
|
||||||
|
|
||||||
|
MCP service with HTTP transport, token auth, six tools. New SignalR hub endpoints for Start/Resume/Discard/Finalize/GetPendingDraftCount. Session directory management. `IPlanningTerminalLauncher` implementation for `wt.exe`. Resolves the four unknowns from §5.8.
|
||||||
|
|
||||||
|
Scope files (approximate):
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` (new)
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (new)
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (extend)
|
||||||
|
- `src/ClaudeDo.Worker/Program.cs` (DI + endpoint mapping)
|
||||||
|
- `src/ClaudeDo.App/` or `src/ClaudeDo.Ui/Services/` — `IPlanningTerminalLauncher` + `WindowsTerminalPlanningLauncher`.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Planning/...`
|
||||||
|
|
||||||
|
### 9.3 Plan C — UI (parallel to B after A merges)
|
||||||
|
|
||||||
|
Context menu entries, hierarchy rendering, draft styling, unfinished-session dialog, WorkerClient extensions, delete-with-children handling. During parallel development, mocks the WorkerClient against Plan B's interface contract.
|
||||||
|
|
||||||
|
Scope files (approximate):
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (draft/badge styling)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` (new)
|
||||||
|
|
||||||
|
### 9.4 Integration points between B and C
|
||||||
|
|
||||||
|
Interface contract locked before parallel work begins:
|
||||||
|
- SignalR method names, parameters, return DTOs (listed in §6.6).
|
||||||
|
- `TaskUpdated` event payload unchanged; carries the task's new parent-id and status so the UI can re-bucket.
|
||||||
|
- Session directory path shape: `~/.todo-app/planning-sessions/<parentTaskId>/`.
|
||||||
|
- `mcp.json` and session-file formats are internal to Plan B; UI never reads them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Out of scope (for now)
|
||||||
|
|
||||||
|
- Nested planning (children of children). Explicitly one level.
|
||||||
|
- Cross-list planning (parent in list A, children in list B).
|
||||||
|
- Multi-user collaboration on the same planning session.
|
||||||
|
- Session timeouts / auto-discard.
|
||||||
|
- Planning-session history / audit UI. Directory is kept on finalize but not surfaced.
|
||||||
|
- Re-planning a finalized parent (7c: no).
|
||||||
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Self-Update for App and Installer — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Give ClaudeDo two update paths:
|
||||||
|
|
||||||
|
- **A — App-side update check:** the Avalonia UI checks Gitea for a newer release on startup (and via a manual menu action) and surfaces a dismissible banner. Clicking **Update now** launches the locally installed installer in Update mode and closes the UI.
|
||||||
|
- **B — Installer self-update:** the WPF installer checks for a newer installer binary on launch and offers to replace itself before continuing. After replacement, it proceeds with its normal wizard.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- No silent/background auto-apply of updates. The user always initiates the final update action.
|
||||||
|
- No periodic in-app polling (startup + manual only).
|
||||||
|
- No changes to the release pipeline — release assets (`ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`) stay as they are.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
A new shared library, `ClaudeDo.Releases`, hosts the release-API client, version comparison, checksum verification, and installer self-update logic. Both `ClaudeDo.Installer` and `ClaudeDo.Ui` reference it. This removes the existing duplication between the installer's release plumbing and what the app needs, and keeps a single asset-matching / version-parsing code path.
|
||||||
|
|
||||||
|
```
|
||||||
|
ClaudeDo.Releases (new, netstandard2.0 or net8.0)
|
||||||
|
├── ReleaseClient.cs (moved from Installer/Core)
|
||||||
|
├── IReleaseClient.cs (moved)
|
||||||
|
├── ChecksumVerifier.cs (moved)
|
||||||
|
├── VersionComparer.cs (new)
|
||||||
|
└── SelfUpdater.cs (new — installer self-update mechanism)
|
||||||
|
|
||||||
|
ClaudeDo.Installer (WPF, consumes ClaudeDo.Releases)
|
||||||
|
├── App.xaml.cs (modified — SelfUpdater + --replace-self arg)
|
||||||
|
└── Core/InstallModeDetector.cs (modified — now uses VersionComparer)
|
||||||
|
|
||||||
|
ClaudeDo.Ui (Avalonia, consumes ClaudeDo.Releases)
|
||||||
|
├── Services/UpdateCheckService.cs (new)
|
||||||
|
├── Services/InstallerLocator.cs (new)
|
||||||
|
├── ViewModels/MainViewModel.cs (modified — banner + Help menu state)
|
||||||
|
└── Views/MainWindow.axaml (modified — banner + Help dropdown)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part A — App-Side Update Check
|
||||||
|
|
||||||
|
### `ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Read the current app version from `Assembly.GetExecutingAssembly().GetName().Version`.
|
||||||
|
- Call `IReleaseClient.GetLatestReleaseAsync` to fetch the Gitea release.
|
||||||
|
- Use `VersionComparer` to decide whether the latest is newer.
|
||||||
|
- Expose observable properties: `IsUpdateAvailable`, `LatestVersion`, `CurrentVersion`, `IsChecking`, `LastCheckStatus` (`UpToDate | UpdateAvailable | CheckFailed | NeverChecked`).
|
||||||
|
- Expose a `CheckNowAsync` method for the manual Help menu action.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
|
||||||
|
- Registered as a singleton in DI.
|
||||||
|
- Startup check is fired from `MainViewModel` once the main window is shown. It runs fire-and-forget on a background `Task`; UI never blocks on it.
|
||||||
|
- Manual check is awaited by its command and briefly shows a status message (see UI).
|
||||||
|
|
||||||
|
Error handling:
|
||||||
|
|
||||||
|
- Network / API errors → log to `~/.todo-app/logs/`, set `LastCheckStatus = CheckFailed`, do not surface a banner. Manual check shows a small inline status only.
|
||||||
|
|
||||||
|
### `ClaudeDo.Ui/Services/InstallerLocator.cs`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Resolve the path to the installed `ClaudeDo.Installer.exe` so the UI can launch it.
|
||||||
|
|
||||||
|
Discovery strategy (first hit wins):
|
||||||
|
|
||||||
|
1. Walk up from `AppContext.BaseDirectory` looking for a sibling `install.json`. The installer is at `{installDir}/uninstaller/ClaudeDo.Installer.exe`.
|
||||||
|
2. Fall back to reading `HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo\InstallLocation` (written by the existing `WriteUninstallRegistryStep`).
|
||||||
|
|
||||||
|
If neither yields a valid path, the banner's **Update now** button is disabled with a tooltip explaining the installer could not be located.
|
||||||
|
|
||||||
|
### UI changes
|
||||||
|
|
||||||
|
**Banner** at the top of `MainView` (above content, below custom chrome):
|
||||||
|
|
||||||
|
- Visible when `UpdateCheckService.IsUpdateAvailable == true` and the user has not dismissed this session.
|
||||||
|
- Text: `Update available: v{CurrentVersion} → v{LatestVersion}`.
|
||||||
|
- Actions: `Update now` (primary), `Dismiss` (sets a transient `IsBannerDismissed` flag that resets on app restart — intentionally not persisted so the banner returns next launch if still relevant).
|
||||||
|
- Styled to match existing chrome/accent conventions (compact, dismissible, non-modal).
|
||||||
|
|
||||||
|
**Help dropdown** in the custom titlebar:
|
||||||
|
|
||||||
|
- New menu: `Help`.
|
||||||
|
- First item: `Check for updates` → binds to `MainViewModel.CheckForUpdatesCommand`, which calls `UpdateCheckService.CheckNowAsync`.
|
||||||
|
- When a check completes:
|
||||||
|
- `UpdateAvailable` → banner appears (no separate dialog).
|
||||||
|
- `UpToDate` → a brief inline status in the banner area: `You're up to date (v{CurrentVersion})`, auto-hides after ~3 seconds.
|
||||||
|
- `CheckFailed` → `Could not check for updates` inline message, auto-hides after ~3 seconds.
|
||||||
|
- Leaves room for future items (`About`, `Documentation`, etc.).
|
||||||
|
|
||||||
|
### Update action flow
|
||||||
|
|
||||||
|
1. User clicks **Update now** in the banner.
|
||||||
|
2. `MainViewModel` resolves the installer path via `InstallerLocator`.
|
||||||
|
3. UI spawns `ClaudeDo.Installer.exe` with no arguments. The installer's existing `InstallModeDetector` reads `install.json` alongside it, hits Gitea, and enters `Update` mode.
|
||||||
|
4. UI closes itself immediately after spawning the process.
|
||||||
|
5. The installer performs its standard update flow: `StopServiceStep` → `DownloadAndExtractStep` → `StartServiceStep`.
|
||||||
|
6. When the user clicks Finish the installer exits. The user re-launches the app via their existing shortcut.
|
||||||
|
|
||||||
|
No IPC between UI and installer is needed — the installer is already self-sufficient once launched against an existing install directory.
|
||||||
|
|
||||||
|
## Part B — Installer Self-Update
|
||||||
|
|
||||||
|
### `ClaudeDo.Releases/SelfUpdater.cs`
|
||||||
|
|
||||||
|
Runs from `ClaudeDo.Installer/App.xaml.cs` before any window is shown.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. **Handle `--replace-self` argument first.** If the installer was launched with `--replace-self "<old-path>"`:
|
||||||
|
- Wait up to 5 seconds for the old process to exit (poll for file lock release).
|
||||||
|
- Delete `<old-path>`.
|
||||||
|
- Copy own exe to `<old-path>`.
|
||||||
|
- Start a new process at `<old-path>` with no args, then exit the current (temp) process. This ensures the user's shortcut or Apps & Features entry now points at the updated binary.
|
||||||
|
- If any step fails, fall through to the normal wizard (the user still has a working installer, just in a temp location).
|
||||||
|
|
||||||
|
2. **Check for a newer installer.** If no `--replace-self` arg:
|
||||||
|
- Parse own assembly version.
|
||||||
|
- Fetch latest release.
|
||||||
|
- Find the installer asset matching `ClaudeDo.Installer-<version>.exe` (regex: `^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$`).
|
||||||
|
- Compare via `VersionComparer`.
|
||||||
|
- If not newer, or if check fails, proceed to the normal wizard (existing Config-mode fallback behavior).
|
||||||
|
|
||||||
|
3. **Prompt if newer.** Show a small modal dialog (plain WPF `Window`, reusing the installer's titlebar/accent styles):
|
||||||
|
> *"A newer installer is available: v{latest}. Update before continuing?"*
|
||||||
|
> `[Update] [Continue anyway] [Cancel]`
|
||||||
|
|
||||||
|
- **Cancel** → `Application.Current.Shutdown(0)`.
|
||||||
|
- **Continue anyway** → proceed to normal wizard.
|
||||||
|
- **Update** → run relaunch sequence.
|
||||||
|
|
||||||
|
4. **Relaunch sequence:**
|
||||||
|
- Download to `%TEMP%\ClaudeDo.Installer-<version>.exe` (show a minimal inline progress UI; no separate window).
|
||||||
|
- Verify against `checksums.txt` from the release via `ChecksumVerifier`. On failure, show error with `[Continue with current installer]` action → proceed to wizard.
|
||||||
|
- `Process.Start` new exe with args `--replace-self "<current-exe-path>"`.
|
||||||
|
- Exit current process.
|
||||||
|
|
||||||
|
### Why `--replace-self` rather than a shell script
|
||||||
|
|
||||||
|
A child process holding a handle to the new exe is reliable cross-Windows-version. Relying on a `.bat` or `cmd /c` helper leaves a file that we would need to clean up, and behaves badly when the installer was launched from a mounted share or non-ASCII path. The `--replace-self` approach keeps everything in managed code and uses a single exe throughout.
|
||||||
|
|
||||||
|
### Edge case: running from `uninstaller/` copy
|
||||||
|
|
||||||
|
When the installer runs from `{installDir}/uninstaller/ClaudeDo.Installer.exe` (via the app's **Update now** or from Apps & Features), the self-update flow is identical. It is desirable for the uninstaller copy to be kept current — stale uninstaller binaries would otherwise drift behind and could have bugs the new app release expects fixed.
|
||||||
|
|
||||||
|
## Version Comparison (`VersionComparer`)
|
||||||
|
|
||||||
|
Centralizes the logic currently in `InstallModeDetector.IsNewer`:
|
||||||
|
|
||||||
|
- Parses both versions as `System.Version` after trimming a leading `v` / `V`.
|
||||||
|
- Returns `(bool isNewer, bool unparseable)`.
|
||||||
|
- Unparseable (e.g. `0.2.0-beta`) → treated as not newer; callers can surface a hint if desired.
|
||||||
|
|
||||||
|
Both `InstallModeDetector` (existing behavior) and `SelfUpdater` / `UpdateCheckService` (new callers) share this logic.
|
||||||
|
|
||||||
|
## Error Handling Summary
|
||||||
|
|
||||||
|
| Scenario | App behavior | Installer behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| Gitea unreachable | Silent; log to file; no banner | Silent; skip self-update; proceed to wizard |
|
||||||
|
| JSON parse error | Same as unreachable | Same as unreachable |
|
||||||
|
| Version unparseable | No banner; log a hint | No prompt; proceed |
|
||||||
|
| Installer exe not found on disk | `Update now` button disabled with tooltip | N/A |
|
||||||
|
| Download fails | N/A (app delegates to installer) | Error dialog with `[Continue with current installer]` |
|
||||||
|
| Checksum mismatch | N/A | Error dialog with `[Continue with current installer]` |
|
||||||
|
| Relaunch fails | N/A | Error dialog; user keeps temp exe and current exe both |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**New test project: `tests/ClaudeDo.Releases.Tests`**
|
||||||
|
|
||||||
|
- `ReleaseClientTests` — move existing installer tests covering `GetLatestReleaseAsync` and `DownloadAsync`.
|
||||||
|
- `VersionComparerTests` — boundary cases (equal, newer, older, unparseable, mixed `v`-prefix).
|
||||||
|
- `SelfUpdaterTests`:
|
||||||
|
- Asset-name regex correctly isolates version from `ClaudeDo.Installer-0.3.0.exe` and ignores `ClaudeDo-0.3.0-win-x64.zip`.
|
||||||
|
- Decision logic given mocked `IReleaseClient` responses.
|
||||||
|
- `--replace-self` handler: given a temp dummy file, the handler waits, deletes, copies — verified with a mock filesystem / temp dir.
|
||||||
|
|
||||||
|
**Existing project: `tests/ClaudeDo.Installer.Tests`**
|
||||||
|
|
||||||
|
- `SelfUpdateIntegrationTest`: build the installer, invoke it with `--replace-self <dummy>` pointing at a temp file, assert the dummy is replaced by a copy of the test installer, and the process exits cleanly. Run only on Windows CI.
|
||||||
|
|
||||||
|
**App tests (`tests/ClaudeDo.Ui.Tests` — add if absent):**
|
||||||
|
|
||||||
|
- `UpdateCheckServiceTests` — stubbed `IReleaseClient`, assert state transitions for each status.
|
||||||
|
- `InstallerLocatorTests` — fake filesystem, verify walk-up and registry-fallback discovery.
|
||||||
|
|
||||||
|
**Manual verification** (add to `docs/open.md`):
|
||||||
|
|
||||||
|
1. Build `v0.2.x` installer and upload to a test Gitea release.
|
||||||
|
2. Tag `v0.3.0` with new installer asset.
|
||||||
|
3. Install `v0.2.x`, run the `v0.2.x` installer again — confirm self-update prompt appears and replaces the binary in place.
|
||||||
|
4. With `v0.2.x` installed and a v0.3.0 release published, launch the app — confirm banner appears, **Update now** launches installer, update completes, app relaunches at v0.3.0.
|
||||||
|
5. Pull the network during check in both places — confirm silent fallback, no user-visible errors.
|
||||||
|
|
||||||
|
## Files Summary
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
|
||||||
|
- `src/ClaudeDo.Releases/ReleaseClient.cs` (moved)
|
||||||
|
- `src/ClaudeDo.Releases/IReleaseClient.cs` (moved)
|
||||||
|
- `src/ClaudeDo.Releases/ChecksumVerifier.cs` (moved)
|
||||||
|
- `src/ClaudeDo.Releases/VersionComparer.cs` (new)
|
||||||
|
- `src/ClaudeDo.Releases/SelfUpdater.cs` (new)
|
||||||
|
- `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
|
||||||
|
- `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Installer/App.xaml.cs` — self-update run + `--replace-self` handling before wizard.
|
||||||
|
- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use shared `VersionComparer`; drop now-moved types.
|
||||||
|
- `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — reference `ClaudeDo.Releases`.
|
||||||
|
- `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — reference `ClaudeDo.Releases`.
|
||||||
|
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner + Help menu dropdown.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/MainViewModel.cs` — banner state, `CheckForUpdatesCommand`, wiring to `UpdateCheckService`.
|
||||||
|
- `src/ClaudeDo.App/Program.cs` (or existing DI composition root) — register `UpdateCheckService`, `InstallerLocator`, `IReleaseClient`, `HttpClient`.
|
||||||
|
- `ClaudeDo.slnx` — add new projects.
|
||||||
|
- `docs/open.md` — add manual verification checklist.
|
||||||
|
|
||||||
|
## Open Decisions Deferred to Implementation
|
||||||
|
|
||||||
|
- Exact Avalonia styling/layout of the banner is left to implementation to match the existing chrome polish pass from commit `3c420ac`.
|
||||||
|
- The Help dropdown control type (Avalonia `MenuItem` inside a `Menu`, or a custom flyout) is chosen during implementation based on what fits the current custom titlebar.
|
||||||
121
docs/superpowers/specs/2026-04-23-worker-log-footer-design.md
Normal file
121
docs/superpowers/specs/2026-04-23-worker-log-footer-design.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Worker Log Footer — Design
|
||||||
|
|
||||||
|
Date: 2026-04-23
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Surface important Worker lifecycle events (worktree created, Claude started, merged, etc.) in the UI footer as a single rotating, color-coded line. Gives the user ambient awareness of what the Worker just did without opening task details.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No log history, drawer, or scrollback
|
||||||
|
- No filtering or user-configurable verbosity
|
||||||
|
- No persistence across UI restarts
|
||||||
|
- No replay of events missed while UI was disconnected
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Footer (`MainWindow.axaml`, row 2) layout changes from `StackPanel` to `DockPanel`:
|
||||||
|
|
||||||
|
- **Docked left:** existing connection pill (ellipse + `ONLINE/OFFLINE/RECONNECTING` text). The static `· WORKER` label is removed; the rotating log line replaces its purpose.
|
||||||
|
- **Docked right:** rotating worker-log line.
|
||||||
|
|
||||||
|
Line format: `14:32 · <message>`, rendered in the mono font at size 10 (matches existing footer typography). `TextTrimming="CharacterEllipsis"` so long task titles don't push out the connection pill.
|
||||||
|
|
||||||
|
The line is hidden when no event has been received within the last 30 seconds. Each new event replaces the current text and resets the 30-second timer. Timestamp is local time, `HH:mm`.
|
||||||
|
|
||||||
|
### Color mapping
|
||||||
|
|
||||||
|
Level is rendered via a `WorkerLogLevelToBrushConverter` (mirrors existing `StatusColorConverter` pattern):
|
||||||
|
|
||||||
|
| Level | Brush / color | Events |
|
||||||
|
|-----------|------------------------|-----------------------------------------------------|
|
||||||
|
| `Info` | `TextDimBrush` (dim) | Created worktree, Started Claude, Committed changes |
|
||||||
|
| `Success` | `#4CAF50` green | Merged, Finished (done) |
|
||||||
|
| `Warn` | `#FFA726` amber | Discarded worktree, Reset |
|
||||||
|
| `Error` | `#EF5350` red | Finished (failed) |
|
||||||
|
|
||||||
|
## Event Catalog
|
||||||
|
|
||||||
|
Seven emit sites. Each is added alongside the existing `_logger.LogInformation(...)` call — no log-sink plumbing, no central event bus.
|
||||||
|
|
||||||
|
| Site | Level | Message |
|
||||||
|
|-------------------------------------------|-----------|-----------------------------------------|
|
||||||
|
| `WorktreeManager.CreateAsync` | `Info` | `Created worktree for "<title>"` |
|
||||||
|
| `WorktreeManager.DiscardAsync` | `Warn` | `Discarded worktree for "<title>"` |
|
||||||
|
| `TaskMergeService.MergeAsync` | `Success` | `Merged "<title>" into <target>` |
|
||||||
|
| `TaskResetService.ResetAsync` | `Warn` | `Reset "<title>"` |
|
||||||
|
| `TaskRunner` — Claude launch | `Info` | `Started Claude for "<title>"` |
|
||||||
|
| `TaskRunner` — auto-commit | `Info` | `Committed changes in "<title>"` |
|
||||||
|
| `TaskRunner` — task finished | `Success` / `Error` | `Finished "<title>" (<status>)` |
|
||||||
|
|
||||||
|
`<title>` is the task's display title; `<target>` is the merge target branch; `<status>` is `done` or `failed`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Shared contract (`ClaudeDo.Data`)
|
||||||
|
|
||||||
|
New enum:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public enum WorkerLogLevel
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SignalR is configured to serialize enums as strings via `JsonStringEnumConverter` (added to the hub's JSON options in `Program.cs`). The UI client deserializes back to the same enum.
|
||||||
|
|
||||||
|
### Server side (`ClaudeDo.Worker`)
|
||||||
|
|
||||||
|
`HubBroadcaster` gets a new method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||||
|
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||||
|
```
|
||||||
|
|
||||||
|
`HubBroadcaster` is already injected into `TaskRunner`. For `WorktreeManager`, `TaskMergeService`, and `TaskResetService`, add constructor injection where it isn't already present. Each emit site calls `_broadcaster.WorkerLog(...)` with `DateTime.UtcNow` next to the existing `_logger.LogInformation(...)`.
|
||||||
|
|
||||||
|
### Client side (`ClaudeDo.Ui`)
|
||||||
|
|
||||||
|
**`WorkerClient`** — register a `HubConnection.On<string, WorkerLogLevel, DateTime>("WorkerLog", ...)` handler and expose a `WorkerLogReceived` event with a small `WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc)` record.
|
||||||
|
|
||||||
|
**Footer VM** — `StatusBarViewModel` already exists; extend it (or introduce a small `FooterViewModel` if `StatusBarViewModel` turns out to be scoped elsewhere — confirm during implementation). Add:
|
||||||
|
|
||||||
|
- `[ObservableProperty] string? currentEventText`
|
||||||
|
- `[ObservableProperty] WorkerLogLevel currentEventLevel`
|
||||||
|
- `[ObservableProperty] bool isEventVisible`
|
||||||
|
- A `DispatcherTimer` with a 30-second interval. On each `WorkerLogReceived`:
|
||||||
|
1. Format `HH:mm · <message>` from the event's local time.
|
||||||
|
2. Set `CurrentEventText`, `CurrentEventLevel`, `IsEventVisible = true`.
|
||||||
|
3. Stop and restart the timer.
|
||||||
|
- On timer tick: `IsEventVisible = false`, `CurrentEventText = null`.
|
||||||
|
|
||||||
|
**XAML** — `MainWindow.axaml` footer `StackPanel` becomes a `DockPanel`. Existing ellipses + connection text dock left in a horizontal `StackPanel`. A new `TextBlock` docks right, bound to `CurrentEventText` with `Foreground="{Binding CurrentEventLevel, Converter={StaticResource WorkerLogLevelToBrush}}"`, `IsVisible="{Binding IsEventVisible}"`, and `TextTrimming="CharacterEllipsis"`. Same mono font / size 10 as the rest of the footer.
|
||||||
|
|
||||||
|
**Converter** — `WorkerLogLevelToBrushConverter` in `Converters/` returns a brush per enum value, resolving theme brushes via `Application.Current.Resources` for `Info` (to honor theme swaps) and hard-coding the success/warn/error hex values (those are already hard-coded in the current footer).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit test `WorkerLogLevelToBrushConverter` with each enum value.
|
||||||
|
- Unit test the footer VM: receiving an event sets text/level/visibility and schedules a clear; a second event within 30s replaces and resets the timer; after 30s of silence the line hides.
|
||||||
|
- Manual smoke: run a task end-to-end and confirm each of the seven events surfaces with the expected color and copy.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- **UI disconnected during an event:** event is lost. Acceptable — reconnect resumes receiving new events.
|
||||||
|
- **Burst of events:** each replaces the previous; only the most recent is shown.
|
||||||
|
- **Long task title:** ellipsized by the TextBlock; connection pill on the left stays fully visible.
|
||||||
|
- **Clock skew between Worker and UI:** timestamp is formatted in UI's local time from the wire-format `DateTime` (sent as UTC). Minor skew is cosmetic; no correctness impact.
|
||||||
|
|
||||||
|
## Out of Scope / Future
|
||||||
|
|
||||||
|
- Click-to-expand history drawer
|
||||||
|
- Per-list or per-task event filtering
|
||||||
|
- Persisting the most recent N events across restarts
|
||||||
@@ -2,33 +2,26 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="ClaudeDo.App.App"
|
x:Class="ClaudeDo.App.App"
|
||||||
xmlns:local="using:ClaudeDo.App"
|
xmlns:local="using:ClaudeDo.App"
|
||||||
|
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||||
RequestedThemeVariant="Dark">
|
RequestedThemeVariant="Dark">
|
||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<!-- Accent: Forest Teal -->
|
<ResourceDictionary>
|
||||||
<SolidColorBrush x:Key="AccentBrush" Color="#3d9474"/>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<SolidColorBrush x:Key="AccentLightBrush" Color="#6bb89e"/>
|
<ResourceInclude Source="avares://ClaudeDo.Ui/Design/Tokens.axaml" />
|
||||||
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#1A3D9474"/>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#263D9474"/>
|
|
||||||
|
|
||||||
<!-- Text -->
|
<!-- Converters -->
|
||||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#f1f5f9"/>
|
<converters:NotNullToBoolConverter x:Key="NotNullToBool"/>
|
||||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#c8d0dc"/>
|
<converters:StrikeIfTrueConverter x:Key="StrikeIfTrue"/>
|
||||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#8892a2"/>
|
<converters:EqStatusConverter x:Key="EqStatus"/>
|
||||||
<SolidColorBrush x:Key="TextDimBrush" Color="#6b7688"/>
|
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
||||||
|
<converters:IconKeyConverter x:Key="IconKey"/>
|
||||||
|
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||||
|
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||||
|
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||||
|
|
||||||
<!-- Borders & Backgrounds -->
|
</ResourceDictionary>
|
||||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#3a3f46"/>
|
|
||||||
<SolidColorBrush x:Key="WindowBgBrush" Color="#1c1e21"/>
|
|
||||||
<SolidColorBrush x:Key="IslandBgBrush" Color="#272a2e"/>
|
|
||||||
<SolidColorBrush x:Key="SidebarBgBrush" Color="#272a2e"/>
|
|
||||||
<SolidColorBrush x:Key="ContentBgBrush" Color="#272a2e"/>
|
|
||||||
|
|
||||||
<!-- Status colors (for checkboxes) -->
|
|
||||||
<SolidColorBrush x:Key="StatusGrayBrush" Color="#475569"/>
|
|
||||||
<SolidColorBrush x:Key="StatusOrangeBrush" Color="#e67e22"/>
|
|
||||||
<SolidColorBrush x:Key="StatusGreenBrush" Color="#3d9474"/>
|
|
||||||
<SolidColorBrush x:Key="StatusRedBrush" Color="#ef4444"/>
|
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
@@ -37,14 +30,15 @@
|
|||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="#333d9474"/>
|
<Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
|
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="#1A3D9474"/>
|
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter">
|
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="#403D9474"/>
|
<Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.Views;
|
using ClaudeDo.Ui.Views;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -22,8 +23,12 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
{
|
{
|
||||||
DataContext = Services.GetRequiredService<MainWindowViewModel>(),
|
DataContext = Services.GetRequiredService<IslandsShellViewModel>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
|
||||||
|
// is not up yet, or goes down and comes back.
|
||||||
|
_ = Services.GetRequiredService<WorkerClient>().StartAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 44 KiB |
@@ -1,13 +1,17 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Ui;
|
using ClaudeDo.Ui;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace ClaudeDo.App;
|
namespace ClaudeDo.App;
|
||||||
@@ -28,8 +32,8 @@ sealed class Program
|
|||||||
|
|
||||||
using (var scope = services.CreateScope())
|
using (var scope = services.CreateScope())
|
||||||
{
|
{
|
||||||
ClaudeDoDbContext.MigrateAndConfigure(
|
var db = scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>();
|
||||||
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
ClaudeDoDbContext.MigrateAndConfigure(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -74,31 +78,42 @@ sealed class Program
|
|||||||
sc.AddSingleton<GitService>();
|
sc.AddSingleton<GitService>();
|
||||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||||
|
|
||||||
// ViewModels
|
// Release check + installer update
|
||||||
sc.AddTransient<ListEditorViewModel>();
|
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
||||||
sc.AddTransient<TaskEditorViewModel>();
|
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||||
sc.AddSingleton<StatusBarViewModel>();
|
sc.AddSingleton<InstallerLocator>();
|
||||||
sc.AddSingleton<TaskDetailViewModel>();
|
sc.AddSingleton(sp =>
|
||||||
sc.AddSingleton<TaskListViewModel>(sp =>
|
|
||||||
{
|
{
|
||||||
var dbFactory = sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>();
|
var releases = sp.GetRequiredService<IReleaseClient>();
|
||||||
var worker = sp.GetRequiredService<WorkerClient>();
|
var informational = Assembly.GetEntryAssembly()?
|
||||||
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||||
return new TaskListViewModel(
|
// Strip MinVer build metadata ("+sha") and any prerelease suffix for the update-compare.
|
||||||
dbFactory, worker,
|
var version = (informational ?? "0.0.0").Split('+')[0];
|
||||||
() => sp.GetRequiredService<TaskEditorViewModel>(),
|
return new UpdateCheckService(releases, version);
|
||||||
msg => statusBar.ShowMessage(msg));
|
|
||||||
});
|
});
|
||||||
sc.AddSingleton<MainWindowViewModel>(sp =>
|
|
||||||
{
|
// ViewModels
|
||||||
return new MainWindowViewModel(
|
sc.AddTransient<WorktreeModalViewModel>();
|
||||||
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
|
sc.AddTransient<MergeModalViewModel>();
|
||||||
|
sc.AddTransient<ListSettingsModalViewModel>();
|
||||||
|
|
||||||
|
// Islands shell VMs
|
||||||
|
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||||
|
new ListsIslandViewModel(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
sp,
|
||||||
|
sp.GetRequiredService<WorkerClient>()));
|
||||||
|
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||||
|
new TasksIslandViewModel(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
sp.GetRequiredService<WorkerClient>()));
|
||||||
|
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||||
|
new DetailsIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<WorkerClient>(),
|
||||||
sp.GetRequiredService<TaskListViewModel>(),
|
sp));
|
||||||
sp.GetRequiredService<TaskDetailViewModel>(),
|
sc.AddSingleton<IslandsShellViewModel>();
|
||||||
sp.GetRequiredService<StatusBarViewModel>(),
|
|
||||||
() => sp.GetRequiredService<ListEditorViewModel>());
|
|
||||||
});
|
|
||||||
|
|
||||||
return sc.BuildServiceProvider();
|
return sc.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,23 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType
|
- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes
|
||||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
||||||
|
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
|
||||||
- **TagEntity** — Id (autoincrement), Name (unique)
|
- **TagEntity** — Id (autoincrement), Name (unique)
|
||||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||||
|
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
|
||||||
|
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||||
|
|
||||||
## Repositories
|
## Repositories
|
||||||
|
|
||||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
|
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
|
||||||
|
|
||||||
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
|
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`, `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides)
|
||||||
- **ListRepository** — CRUD, tag junction management
|
- **ListRepository** — CRUD, tag junction management, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||||
- **TagRepository** — `GetOrCreateAsync` (idempotent)
|
- **TagRepository** — `GetOrCreateAsync` (idempotent)
|
||||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||||
|
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T
|
|||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
|
Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Seeding;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
@@ -16,6 +17,7 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||||
|
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -43,6 +45,13 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
walCmd.ExecuteNonQuery();
|
walCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable FK enforcement — SQLite defaults to OFF per connection.
|
||||||
|
using (var fkCmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
fkCmd.CommandText = "PRAGMA foreign_keys=ON;";
|
||||||
|
fkCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
||||||
// this is a pre-EF database. Baseline the InitialCreate migration.
|
// this is a pre-EF database. Baseline the InitialCreate migration.
|
||||||
using (var cmd = conn.CreateCommand())
|
using (var cmd = conn.CreateCommand())
|
||||||
@@ -73,5 +82,6 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
|
DefaultListsSeeder.SeedAsync(db).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Configuration;
|
||||||
|
|
||||||
|
public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettingsEntity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<AppSettingsEntity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("app_settings");
|
||||||
|
|
||||||
|
builder.HasKey(s => s.Id);
|
||||||
|
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
||||||
|
|
||||||
|
builder.Property(s => s.DefaultClaudeInstructions)
|
||||||
|
.HasColumnName("default_claude_instructions").IsRequired().HasDefaultValue(string.Empty);
|
||||||
|
builder.Property(s => s.DefaultModel)
|
||||||
|
.HasColumnName("default_model").IsRequired().HasDefaultValue("sonnet");
|
||||||
|
builder.Property(s => s.DefaultMaxTurns)
|
||||||
|
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(30);
|
||||||
|
builder.Property(s => s.DefaultPermissionMode)
|
||||||
|
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
|
||||||
|
|
||||||
|
builder.Property(s => s.WorktreeStrategy)
|
||||||
|
.HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling");
|
||||||
|
builder.Property(s => s.CentralWorktreeRoot)
|
||||||
|
.HasColumnName("central_worktree_root");
|
||||||
|
builder.Property(s => s.WorktreeAutoCleanupEnabled)
|
||||||
|
.HasColumnName("worktree_auto_cleanup_enabled").IsRequired().HasDefaultValue(false);
|
||||||
|
builder.Property(s => s.WorktreeAutoCleanupDays)
|
||||||
|
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
|
||||||
|
|
||||||
|
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == TaskStatus.Running ? "running"
|
: v == TaskStatus.Running ? "running"
|
||||||
: v == TaskStatus.Done ? "done"
|
: v == TaskStatus.Done ? "done"
|
||||||
: v == TaskStatus.Failed ? "failed"
|
: v == TaskStatus.Failed ? "failed"
|
||||||
|
: v == TaskStatus.Planning ? "planning"
|
||||||
|
: v == TaskStatus.Planned ? "planned"
|
||||||
|
: v == TaskStatus.Draft ? "draft"
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static TaskStatus StatusFromString(string v)
|
private static TaskStatus StatusFromString(string v)
|
||||||
@@ -22,6 +25,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == "running" ? TaskStatus.Running
|
: v == "running" ? TaskStatus.Running
|
||||||
: v == "done" ? TaskStatus.Done
|
: v == "done" ? TaskStatus.Done
|
||||||
: v == "failed" ? TaskStatus.Failed
|
: v == "failed" ? TaskStatus.Failed
|
||||||
|
: v == "planning" ? TaskStatus.Planning
|
||||||
|
: v == "planned" ? TaskStatus.Planned
|
||||||
|
: v == "draft" ? TaskStatus.Draft
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||||
@@ -48,6 +54,20 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.Model).HasColumnName("model");
|
builder.Property(t => t.Model).HasColumnName("model");
|
||||||
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
|
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
|
||||||
builder.Property(t => t.AgentPath).HasColumnName("agent_path");
|
builder.Property(t => t.AgentPath).HasColumnName("agent_path");
|
||||||
|
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
|
||||||
|
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
|
||||||
|
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||||
|
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
|
|
||||||
|
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||||
|
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||||
|
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
|
||||||
|
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
builder.HasOne(t => t.Parent)
|
||||||
|
.WithMany(t => t.Children)
|
||||||
|
.HasForeignKey(t => t.ParentTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
builder.HasOne(t => t.List)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.WithMany(l => l.Tasks)
|
||||||
@@ -71,5 +91,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
|
|
||||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||||
|
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||||
|
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ public sealed class GitService
|
|||||||
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||||
@@ -50,6 +58,36 @@ public sealed class GitService
|
|||||||
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||||
|
["diff", "HEAD"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
|
||||||
|
// If nothing staged vs HEAD, try the index (untracked is never in diff)
|
||||||
|
if (string.IsNullOrWhiteSpace(stdout))
|
||||||
|
{
|
||||||
|
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
|
||||||
|
if (e2 == 0) return s2;
|
||||||
|
}
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full diff between <paramref name="baseRef"/> and the current working tree
|
||||||
|
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
|
||||||
|
/// task's total impact relative to where the branch started.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
|
||||||
|
["diff", baseRef], ct);
|
||||||
|
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
|
||||||
|
return stdout;
|
||||||
|
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
|
||||||
|
return await GetDiffAsync(worktreePath, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||||
@@ -70,6 +108,41 @@ public sealed class GitService
|
|||||||
throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> ListWorktreePathsForBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct);
|
||||||
|
if (exitCode != 0) return new();
|
||||||
|
|
||||||
|
var target = $"refs/heads/{branchName}";
|
||||||
|
var paths = new List<string>();
|
||||||
|
string? currentPath = null;
|
||||||
|
foreach (var raw in stdout.Split('\n'))
|
||||||
|
{
|
||||||
|
var line = raw.TrimEnd('\r');
|
||||||
|
if (line.StartsWith("worktree ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
currentPath = line["worktree ".Length..].Trim();
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("branch ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var b = line["branch ".Length..].Trim();
|
||||||
|
if (b == target && currentPath is not null) paths.Add(currentPath);
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
currentPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git worktree prune failed (exit {exitCode}): {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default)
|
public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var flag = force ? "-D" : "-d";
|
var flag = force ? "-D" : "-d";
|
||||||
@@ -78,6 +151,73 @@ public sealed class GitService
|
|||||||
throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["symbolic-ref", "--short", "HEAD"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git symbolic-ref --short HEAD failed (exit {exitCode}): {stderr}");
|
||||||
|
return stdout.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckoutBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["checkout", branchName], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git checkout '{branchName}' failed (exit {exitCode}): {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["branch", "--format=%(refname:short)"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git branch --format failed (exit {exitCode}): {stderr}");
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(s => s.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
|
||||||
|
if (exitCode != 0) return false;
|
||||||
|
var gitDir = stdout.Trim();
|
||||||
|
if (!Path.IsPathRooted(gitDir))
|
||||||
|
gitDir = Path.Combine(repoDir, gitDir);
|
||||||
|
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||||
|
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||||
|
return (exitCode, stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MergeAbortAsync(string repoDir, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--abort"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["diff", "--name-only", "--diff-filter=U"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git diff --diff-filter=U failed (exit {exitCode}): {stderr}");
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(s => s.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||||
@@ -96,6 +236,9 @@ public sealed class GitService
|
|||||||
RedirectStandardInput = stdinData is not null,
|
RedirectStandardInput = stdinData is not null,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8,
|
||||||
|
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null,
|
||||||
};
|
};
|
||||||
psi.ArgumentList.Add("-C");
|
psi.ArgumentList.Add("-C");
|
||||||
psi.ArgumentList.Add(workDir);
|
psi.ArgumentList.Add(workDir);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskFlagsAndNotes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "is_my_day",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "is_starred",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "notes",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "is_my_day",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "is_starred",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "notes",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAppSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "app_settings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
default_claude_instructions = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
|
||||||
|
default_model = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "sonnet"),
|
||||||
|
default_max_turns = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 30),
|
||||||
|
default_permission_mode = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "bypassPermissions"),
|
||||||
|
worktree_strategy = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "sibling"),
|
||||||
|
central_worktree_root = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
worktree_auto_cleanup_enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||||
|
worktree_auto_cleanup_days = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 7)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_app_settings", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "app_settings",
|
||||||
|
columns: new[] { "id", "central_worktree_root", "default_claude_instructions", "default_max_turns", "default_model", "default_permission_mode", "worktree_auto_cleanup_days", "worktree_strategy" },
|
||||||
|
values: new object[] { 1, null, "", 30, "sonnet", "bypassPermissions", 7, "sibling" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "app_settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskSortOrder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "sort_order",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
// Backfill existing rows with a per-list dense order (0..N-1) by creation time
|
||||||
|
// so today's UI order is preserved after the migration.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (PARTITION BY list_id ORDER BY created_at) - 1) AS rn
|
||||||
|
FROM tasks
|
||||||
|
)
|
||||||
|
UPDATE tasks SET sort_order = (SELECT rn FROM ordered WHERE ordered.id = tasks.id);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_tasks_list_sort",
|
||||||
|
table: "tasks",
|
||||||
|
columns: new[] { "list_id", "sort_order" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_tasks_list_sort",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "sort_order",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPlanningSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "planning_finalized_at",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "planning_session_id",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "planning_session_token",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_tasks_parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
column: "parent_task_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_tasks_tasks_parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
column: "parent_task_id",
|
||||||
|
principalTable: "tasks",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_tasks_tasks_parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_tasks_parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_finalized_at",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_session_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_session_token",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,80 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
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<string>("DefaultClaudeInstructions")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("")
|
||||||
|
.HasColumnName("default_claude_instructions");
|
||||||
|
|
||||||
|
b.Property<int>("DefaultMaxTurns")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(30)
|
||||||
|
.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>("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,
|
||||||
|
DefaultClaudeInstructions = "",
|
||||||
|
DefaultMaxTurns = 30,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "bypassPermissions",
|
||||||
|
WorktreeAutoCleanupDays = 7,
|
||||||
|
WorktreeAutoCleanupEnabled = false,
|
||||||
|
WorktreeStrategy = "sibling"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("ListId")
|
b.Property<string>("ListId")
|
||||||
@@ -170,6 +244,18 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("finished_at");
|
.HasColumnName("finished_at");
|
||||||
|
|
||||||
|
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")
|
b.Property<string>("ListId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
@@ -183,6 +269,26 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("model");
|
.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>("PlanningSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_id");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionToken")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_token");
|
||||||
|
|
||||||
b.Property<string>("Result")
|
b.Property<string>("Result")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("result");
|
.HasColumnName("result");
|
||||||
@@ -191,6 +297,12 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("scheduled_for");
|
.HasColumnName("scheduled_for");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
b.Property<DateTime?>("StartedAt")
|
b.Property<DateTime?>("StartedAt")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("started_at");
|
.HasColumnName("started_at");
|
||||||
@@ -214,9 +326,15 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.HasIndex("ListId")
|
b.HasIndex("ListId")
|
||||||
.HasDatabaseName("idx_tasks_list_id");
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
|
|
||||||
b.HasIndex("Status")
|
b.HasIndex("Status")
|
||||||
.HasDatabaseName("idx_tasks_status");
|
.HasDatabaseName("idx_tasks_status");
|
||||||
|
|
||||||
|
b.HasIndex("ListId", "SortOrder")
|
||||||
|
.HasDatabaseName("idx_tasks_list_sort");
|
||||||
|
|
||||||
b.ToTable("tasks", (string)null);
|
b.ToTable("tasks", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -403,7 +521,14 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("List");
|
b.Navigation("List");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||||
@@ -467,6 +592,8 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
|
||||||
b.Navigation("Runs");
|
b.Navigation("Runs");
|
||||||
|
|
||||||
b.Navigation("Subtasks");
|
b.Navigation("Subtasks");
|
||||||
|
|||||||
18
src/ClaudeDo.Data/Models/AppSettingsEntity.cs
Normal file
18
src/ClaudeDo.Data/Models/AppSettingsEntity.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public sealed class AppSettingsEntity
|
||||||
|
{
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int Id { get; set; } = SingletonId;
|
||||||
|
|
||||||
|
public string DefaultClaudeInstructions { get; set; } = string.Empty;
|
||||||
|
public string DefaultModel { get; set; } = "sonnet";
|
||||||
|
public int DefaultMaxTurns { get; set; } = 30;
|
||||||
|
public string DefaultPermissionMode { get; set; } = "bypassPermissions";
|
||||||
|
|
||||||
|
public string WorktreeStrategy { get; set; } = "sibling";
|
||||||
|
public string? CentralWorktreeRoot { get; set; }
|
||||||
|
public bool WorktreeAutoCleanupEnabled { get; set; }
|
||||||
|
public int WorktreeAutoCleanupDays { get; set; } = 7;
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ public enum TaskStatus
|
|||||||
Running,
|
Running,
|
||||||
Done,
|
Done,
|
||||||
Failed,
|
Failed,
|
||||||
|
Planning,
|
||||||
|
Planned,
|
||||||
|
Draft,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TaskEntity
|
public sealed class TaskEntity
|
||||||
@@ -26,6 +29,15 @@ public sealed class TaskEntity
|
|||||||
public string? Model { get; set; }
|
public string? Model { get; set; }
|
||||||
public string? SystemPrompt { get; set; }
|
public string? SystemPrompt { get; set; }
|
||||||
public string? AgentPath { get; set; }
|
public string? AgentPath { get; set; }
|
||||||
|
public bool IsStarred { get; set; }
|
||||||
|
public bool IsMyDay { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public string? ParentTaskId { get; set; }
|
||||||
|
public string? PlanningSessionId { get; set; }
|
||||||
|
public string? PlanningSessionToken { get; set; }
|
||||||
|
public DateTime? PlanningFinalizedAt { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
@@ -33,4 +45,7 @@ public sealed class TaskEntity
|
|||||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
||||||
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
||||||
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
||||||
|
|
||||||
|
public TaskEntity? Parent { get; set; }
|
||||||
|
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/ClaudeDo.Data/Models/WorkerLogLevel.cs
Normal file
9
src/ClaudeDo.Data/Models/WorkerLogLevel.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public enum WorkerLogLevel
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
48
src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs
Normal file
48
src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
|
public sealed class AppSettingsRepository
|
||||||
|
{
|
||||||
|
private readonly ClaudeDoDbContext _context;
|
||||||
|
|
||||||
|
public AppSettingsRepository(ClaudeDoDbContext context) => _context = context;
|
||||||
|
|
||||||
|
public async Task<AppSettingsEntity> GetAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var row = await _context.AppSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||||
|
if (row is not null) return row;
|
||||||
|
|
||||||
|
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||||
|
_context.AppSettings.Add(row);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
_context.Entry(row).State = EntityState.Detached;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var row = await _context.AppSettings
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||||
|
_context.AppSettings.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
|
||||||
|
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
|
||||||
|
row.DefaultMaxTurns = updated.DefaultMaxTurns;
|
||||||
|
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
|
||||||
|
? "bypassPermissions" : updated.DefaultPermissionMode;
|
||||||
|
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
|
||||||
|
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
|
||||||
|
? null : updated.CentralWorktreeRoot;
|
||||||
|
row.WorktreeAutoCleanupEnabled = updated.WorktreeAutoCleanupEnabled;
|
||||||
|
row.WorktreeAutoCleanupDays = updated.WorktreeAutoCleanupDays;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,4 +88,12 @@ public sealed class ListRepository
|
|||||||
}
|
}
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteConfigAsync(string listId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var affected = await _context.ListConfigs
|
||||||
|
.Where(c => c.ListId == listId)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// Append at bottom of the list by default: SortOrder = max(listId) + 1.
|
||||||
|
var maxSort = await _context.Tasks
|
||||||
|
.Where(t => t.ListId == entity.ListId)
|
||||||
|
.Select(t => (int?)t.SortOrder)
|
||||||
|
.MaxAsync(ct);
|
||||||
|
entity.SortOrder = (maxSort ?? -1) + 1;
|
||||||
|
|
||||||
_context.Tasks.Add(entity);
|
_context.Tasks.Add(entity);
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -38,10 +45,32 @@ public sealed class TaskRepository
|
|||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Where(t => t.ListId == listId)
|
.Where(t => t.ListId == listId)
|
||||||
.OrderBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renumbers tasks in a list to 0..N-1 according to <paramref name="orderedTaskIds"/>.
|
||||||
|
/// Ids not belonging to the list are ignored; ids missing from the list are untouched.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ReorderAsync(string listId, IReadOnlyList<string> orderedTaskIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (orderedTaskIds.Count == 0) return;
|
||||||
|
|
||||||
|
var idSet = orderedTaskIds.ToHashSet();
|
||||||
|
var tasks = await _context.Tasks
|
||||||
|
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
for (int i = 0; i < orderedTaskIds.Count; i++)
|
||||||
|
{
|
||||||
|
var task = tasks.FirstOrDefault(t => t.Id == orderedTaskIds[i]);
|
||||||
|
if (task is not null) task.SortOrder = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
// Kept for backwards-compatibility with callers using the old name.
|
// Kept for backwards-compatibility with callers using the old name.
|
||||||
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||||
=> GetByListIdAsync(listId, ct);
|
=> GetByListIdAsync(listId, ct);
|
||||||
@@ -98,6 +127,36 @@ public sealed class TaskRepository
|
|||||||
.SetProperty(t => t.Result, resultText), ct);
|
.SetProperty(t => t.Result, resultText), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ResetToManualAsync(string taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||||
|
.SetProperty(t => t.StartedAt, (DateTime?)null)
|
||||||
|
.SetProperty(t => t.FinishedAt, (DateTime?)null)
|
||||||
|
.SetProperty(t => t.Result, (string?)null), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Agent settings
|
||||||
|
|
||||||
|
public async Task UpdateAgentSettingsAsync(
|
||||||
|
string taskId,
|
||||||
|
string? model,
|
||||||
|
string? systemPrompt,
|
||||||
|
string? agentPath,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Model, model)
|
||||||
|
.SetProperty(t => t.SystemPrompt, systemPrompt)
|
||||||
|
.SetProperty(t => t.AgentPath, agentPath), ct);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Tags
|
#region Tags
|
||||||
@@ -147,6 +206,206 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Planning
|
||||||
|
|
||||||
|
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity> CreateChildAsync(
|
||||||
|
string parentId,
|
||||||
|
string title,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<string>? tagNames,
|
||||||
|
string? commitType,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null)
|
||||||
|
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
||||||
|
|
||||||
|
var maxSort = await _context.Tasks
|
||||||
|
.Where(t => t.ListId == parent.ListId)
|
||||||
|
.Select(t => (int?)t.SortOrder)
|
||||||
|
.MaxAsync(ct);
|
||||||
|
|
||||||
|
var child = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = parent.ListId,
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Status = TaskStatus.Draft,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
SortOrder = (maxSort ?? -1) + 1,
|
||||||
|
};
|
||||||
|
_context.Tasks.Add(child);
|
||||||
|
|
||||||
|
if (tagNames is not null && tagNames.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
|
||||||
|
if (tag is null)
|
||||||
|
{
|
||||||
|
tag = new TagEntity { Name = tagName };
|
||||||
|
_context.Tags.Add(tag);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
child.Tags.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
||||||
|
string taskId,
|
||||||
|
string sessionToken,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var affected = await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Planning)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||||
|
|
||||||
|
if (affected == 0) return null;
|
||||||
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePlanningSessionIdAsync(
|
||||||
|
string parentId,
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
||||||
|
string token,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> FinalizePlanningAsync(
|
||||||
|
string parentId,
|
||||||
|
bool queueAgentTasks,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var parent = await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.List).ThenInclude(l => l.Tags)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||||
|
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
|
||||||
|
|
||||||
|
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
|
||||||
|
|
||||||
|
var drafts = await _context.Tasks
|
||||||
|
.Include(t => t.Tags)
|
||||||
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var draft in drafts)
|
||||||
|
{
|
||||||
|
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
|
||||||
|
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
||||||
|
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalizedAt = DateTime.UtcNow;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Planned)
|
||||||
|
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DiscardPlanningAsync(
|
||||||
|
string parentId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var parent = await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync(ct);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||||
|
.SetProperty(t => t.PlanningSessionId, (string?)null)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
||||||
|
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||||
|
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TryCompleteParentAsync(
|
||||||
|
string parentId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planned) return;
|
||||||
|
|
||||||
|
var children = await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.Select(t => t.Status)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (children.Count == 0) return;
|
||||||
|
|
||||||
|
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
||||||
|
if (!allTerminal) return;
|
||||||
|
|
||||||
|
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
||||||
|
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
||||||
|
var finishedAt = DateTime.UtcNow;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, finalStatus)
|
||||||
|
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Queue selection
|
#region Queue selection
|
||||||
|
|
||||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||||
@@ -175,7 +434,7 @@ public sealed class TaskRepository
|
|||||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ORDER BY t.created_at ASC
|
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|||||||
@@ -40,4 +40,17 @@ public sealed class WorktreeRepository
|
|||||||
{
|
{
|
||||||
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
|
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<WorktreeEntity>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Worktrees.AsNoTracking().ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<WorktreeEntity>> GetByStatesAsync(
|
||||||
|
IReadOnlyCollection<WorktreeState> states, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Worktrees.AsNoTracking()
|
||||||
|
.Where(w => states.Contains(w.State))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/ClaudeDo.Data/Seeding/DefaultListsSeeder.cs
Normal file
25
src/ClaudeDo.Data/Seeding/DefaultListsSeeder.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Seeding;
|
||||||
|
|
||||||
|
public static class DefaultListsSeeder
|
||||||
|
{
|
||||||
|
private static readonly string[] Defaults = { "My Day", "Important", "Planned" };
|
||||||
|
|
||||||
|
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var existing = await ctx.Lists.Select(l => l.Name).ToListAsync(ct);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
|
Name = name,
|
||||||
|
CreatedAt = now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Net.Http;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Installer.Pages.InstallPage;
|
using ClaudeDo.Installer.Pages.InstallPage;
|
||||||
using ClaudeDo.Installer.Pages.PathsPage;
|
using ClaudeDo.Installer.Pages.PathsPage;
|
||||||
using ClaudeDo.Installer.Pages.ServicePage;
|
using ClaudeDo.Installer.Pages.ServicePage;
|
||||||
@@ -21,6 +22,104 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
// --- Self-update pre-flight ---
|
||||||
|
// Resolve current exe path. Assembly.Location may point to a .dll for apphost-based
|
||||||
|
// .NET apps; swap to the .exe companion when that happens.
|
||||||
|
var currentExePath = Assembly.GetEntryAssembly()!.Location;
|
||||||
|
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arg form: --replace-self "<old-path>"
|
||||||
|
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
|
||||||
|
{
|
||||||
|
var oldPath = e.Args[replaceSelfIndex + 1];
|
||||||
|
var relaunched = await SelfUpdater.HandleReplaceSelfAsync(
|
||||||
|
oldPath: oldPath,
|
||||||
|
currentExePath: currentExePath,
|
||||||
|
launchProcess: path =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
});
|
||||||
|
if (relaunched)
|
||||||
|
{
|
||||||
|
Shutdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Replacement failed — fall through to normal wizard from the temp location.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Normal launch: check for a newer installer.
|
||||||
|
using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
var selfUpdateReleases = new ReleaseClient(selfUpdateHttp);
|
||||||
|
var currentVersion = GetInstallerVersion();
|
||||||
|
|
||||||
|
var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None);
|
||||||
|
if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable)
|
||||||
|
{
|
||||||
|
var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
|
||||||
|
DarkTitleBar.Apply(prompt);
|
||||||
|
var ok = prompt.ShowDialog() == true;
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
Shutdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prompt.Choice == SelfUpdateChoice.Update)
|
||||||
|
{
|
||||||
|
prompt.ShowProgress("Downloading...");
|
||||||
|
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
|
||||||
|
var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync(
|
||||||
|
selfUpdateReleases,
|
||||||
|
decision.InstallerAsset!,
|
||||||
|
decision.ChecksumsAsset!,
|
||||||
|
tempDir,
|
||||||
|
new Progress<long>(_ => { }),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
if (verifiedPath is null)
|
||||||
|
{
|
||||||
|
MessageBox.Show(prompt,
|
||||||
|
"Update download or verification failed. Continuing with current installer.",
|
||||||
|
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = true,
|
||||||
|
};
|
||||||
|
psi.ArgumentList.Add("--replace-self");
|
||||||
|
psi.ArgumentList.Add(currentExePath);
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
Shutdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(prompt,
|
||||||
|
"Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.",
|
||||||
|
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SelfUpdateChoice.Continue — fall through to normal wizard.
|
||||||
|
}
|
||||||
|
// No-update or check failed — fall through to normal wizard.
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Existing wizard start-up unchanged below this line ---
|
||||||
|
|
||||||
_services = BuildServices();
|
_services = BuildServices();
|
||||||
|
|
||||||
var context = _services.GetRequiredService<InstallContext>();
|
var context = _services.GetRequiredService<InstallContext>();
|
||||||
@@ -50,6 +149,7 @@ public partial class App : Application
|
|||||||
context.Mode = state.Mode;
|
context.Mode = state.Mode;
|
||||||
context.InstalledVersion = state.Existing?.Version;
|
context.InstalledVersion = state.Existing?.Version;
|
||||||
context.LatestVersion = state.LatestVersion;
|
context.LatestVersion = state.LatestVersion;
|
||||||
|
context.LatestTagUnparseable = state.LatestTagUnparseable;
|
||||||
if (state.Existing is not null)
|
if (state.Existing is not null)
|
||||||
context.InstallDirectory = state.Existing.InstallDir;
|
context.InstallDirectory = state.Existing.InstallDir;
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class InstallContext
|
|||||||
public string? InstallerVersion { get; set; } // from this installer's assembly
|
public string? InstallerVersion { get; set; } // from this installer's assembly
|
||||||
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
|
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
|
||||||
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
|
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
|
||||||
|
public bool LatestTagUnparseable { get; set; } // true if latest tag isn't a System.Version
|
||||||
|
|
||||||
// PathsPage
|
// PathsPage
|
||||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ public sealed record InstallManifest(
|
|||||||
string Version,
|
string Version,
|
||||||
string InstallDir,
|
string InstallDir,
|
||||||
string WorkerDir,
|
string WorkerDir,
|
||||||
DateTimeOffset InstalledAt);
|
DateTimeOffset InstalledAt,
|
||||||
|
string? DataDir = null);
|
||||||
|
|
||||||
public static class InstallManifestStore
|
public static class InstallManifestStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
public sealed record DetectedState(
|
public sealed record DetectedState(
|
||||||
InstallerMode Mode,
|
InstallerMode Mode,
|
||||||
InstallManifest? Existing,
|
InstallManifest? Existing,
|
||||||
GiteaRelease? LatestRelease,
|
GiteaRelease? LatestRelease,
|
||||||
string? LatestVersion);
|
string? LatestVersion)
|
||||||
|
{
|
||||||
|
/// <summary>True when a release was returned but its tag isn't a parseable
|
||||||
|
/// System.Version (e.g. "0.2.0-beta") — so we couldn't decide if it's newer.</summary>
|
||||||
|
public bool LatestTagUnparseable { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class InstallModeDetector
|
public sealed class InstallModeDetector
|
||||||
{
|
{
|
||||||
@@ -26,23 +33,16 @@ public sealed class InstallModeDetector
|
|||||||
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
||||||
|
|
||||||
var latestVersion = release.TagName.TrimStart('v', 'V');
|
var latestVersion = release.TagName.TrimStart('v', 'V');
|
||||||
if (IsNewer(latestVersion, manifest.Version))
|
var cmp = VersionComparer.Compare(latestVersion, manifest.Version);
|
||||||
|
var newer = cmp.IsNewer;
|
||||||
|
var unparseable = cmp.Unparseable;
|
||||||
|
if (newer)
|
||||||
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
||||||
|
|
||||||
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
|
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion)
|
||||||
|
{
|
||||||
|
LatestTagUnparseable = unparseable,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
|
|
||||||
/// AND latest > current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
|
|
||||||
/// treated as "not newer" — the user drops into Config mode with no update offered.
|
|
||||||
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
|
|
||||||
/// If the project starts shipping pre-release tags, revisit this.
|
|
||||||
/// </summary>
|
|
||||||
private static bool IsNewer(string latest, string current)
|
|
||||||
{
|
|
||||||
if (!Version.TryParse(latest, out var lv)) return false;
|
|
||||||
if (!Version.TryParse(current, out var cv)) return false;
|
|
||||||
return lv > cv;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,10 +67,13 @@ public sealed class UninstallRunner
|
|||||||
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
|
// 6) Delete data dir (config + DB + logs) — only if user opted in.
|
||||||
|
// Prefer the manifest-recorded DataDir so a customised DbPath is honoured;
|
||||||
|
// fall back to the default ~/.todo-app for older manifests.
|
||||||
if (removeAppData)
|
if (removeAppData)
|
||||||
{
|
{
|
||||||
var appData = Paths.AppDataRoot();
|
var manifest = InstallManifestStore.TryRead(_context.InstallDirectory);
|
||||||
|
var appData = manifest?.DataDir ?? Paths.AppDataRoot();
|
||||||
if (Directory.Exists(appData))
|
if (Directory.Exists(appData))
|
||||||
{
|
{
|
||||||
progress.Report($"Deleting {appData}...");
|
progress.Report($"Deleting {appData}...");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Steps;
|
namespace ClaudeDo.Installer.Steps;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -15,6 +16,10 @@ public sealed class InitDatabaseStep : IInstallStep
|
|||||||
var expandedPath = Paths.Expand(ctx.DbPath);
|
var expandedPath = Paths.Expand(ctx.DbPath);
|
||||||
progress.Report($"Initializing database at {expandedPath}");
|
progress.Report($"Initializing database at {expandedPath}");
|
||||||
|
|
||||||
|
var parent = Path.GetDirectoryName(expandedPath);
|
||||||
|
if (!string.IsNullOrEmpty(parent))
|
||||||
|
Directory.CreateDirectory(parent);
|
||||||
|
|
||||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
.UseSqlite($"Data Source={expandedPath}")
|
.UseSqlite($"Data Source={expandedPath}")
|
||||||
.Options;
|
.Options;
|
||||||
|
|||||||
@@ -43,13 +43,21 @@ public sealed class RegisterServiceStep : IInstallStep
|
|||||||
|
|
||||||
// Create service
|
// Create service
|
||||||
var startType = ctx.AutoStart ? "auto" : "demand";
|
var startType = ctx.AutoStart ? "auto" : "demand";
|
||||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
|
||||||
|
|
||||||
if (ctx.ServiceAccount == "CurrentUser")
|
if (ctx.ServiceAccount == "CurrentUser")
|
||||||
return StepResult.Fail(
|
return StepResult.Fail(
|
||||||
"Service cannot run as Current User without a password. " +
|
"Service cannot run as Current User without a password. " +
|
||||||
"Select 'Local System' or extend ServicePage to capture a password.");
|
"Select 'Local System' or extend ServicePage to capture a password.");
|
||||||
|
|
||||||
|
var objArg = ctx.ServiceAccount switch
|
||||||
|
{
|
||||||
|
"LocalSystem" => " obj= LocalSystem",
|
||||||
|
"NetworkService" => " obj= \"NT AUTHORITY\\NetworkService\"",
|
||||||
|
"LocalService" => " obj= \"NT AUTHORITY\\LocalService\"",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}{objArg}";
|
||||||
|
|
||||||
progress.Report("Creating service...");
|
progress.Report("Creating service...");
|
||||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
||||||
if (exitCode == 1072)
|
if (exitCode == 1072)
|
||||||
|
|||||||
@@ -13,15 +13,26 @@ public sealed class StartServiceStep : IInstallStep
|
|||||||
progress.Report($"Starting {ServiceName}...");
|
progress.Report($"Starting {ServiceName}...");
|
||||||
|
|
||||||
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
|
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
|
||||||
if (exit == 0) return StepResult.Ok();
|
// 1056 = ERROR_SERVICE_ALREADY_RUNNING — fine, fall through to the readiness poll.
|
||||||
|
if (exit != 0 && exit != 1056)
|
||||||
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
|
|
||||||
if (exit == 1056)
|
|
||||||
{
|
|
||||||
progress.Report("Service was already running.");
|
|
||||||
return StepResult.Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
|
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
|
||||||
|
|
||||||
|
if (exit == 1056)
|
||||||
|
progress.Report("Service was already running.");
|
||||||
|
|
||||||
|
// sc.exe start returns as soon as SCM accepts the command. Poll until the
|
||||||
|
// service actually reports RUNNING so downstream steps and SignalR clients
|
||||||
|
// don't race the worker's startup.
|
||||||
|
progress.Report("Waiting for service to reach RUNNING state...");
|
||||||
|
for (var i = 0; i < 30; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var (q, output) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
|
||||||
|
if (q == 0 && output.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return StepResult.Ok();
|
||||||
|
await Task.Delay(1000, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StepResult.Fail("Service did not reach RUNNING state within 30 seconds.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Steps;
|
namespace ClaudeDo.Installer.Steps;
|
||||||
@@ -14,11 +15,14 @@ public sealed class WriteInstallManifestStep : IInstallStep
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var dataDir = Path.GetDirectoryName(Paths.Expand(ctx.DbPath));
|
||||||
|
|
||||||
var manifest = new InstallManifest(
|
var manifest = new InstallManifest(
|
||||||
Version: ctx.InstalledVersion,
|
Version: ctx.InstalledVersion,
|
||||||
InstallDir: ctx.InstallDirectory,
|
InstallDir: ctx.InstallDirectory,
|
||||||
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
|
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
|
||||||
InstalledAt: DateTimeOffset.UtcNow);
|
InstalledAt: DateTimeOffset.UtcNow,
|
||||||
|
DataDir: dataDir);
|
||||||
|
|
||||||
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
|
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
|
||||||
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
|
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
|
||||||
|
|||||||
25
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
Normal file
25
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="ClaudeDo Installer Update"
|
||||||
|
Width="460" Height="200"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="#1a1a1a" Foreground="#f0f0f0">
|
||||||
|
<Grid Margin="20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="A newer installer is available"/>
|
||||||
|
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
|
||||||
|
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button x:Name="UpdateBtn" Content="Update" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
|
||||||
|
<Button x:Name="ContinueBtn" Content="Continue anyway" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
|
||||||
|
<Button x:Name="CancelBtn" Content="Cancel" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
42
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
Normal file
42
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Views;
|
||||||
|
|
||||||
|
public enum SelfUpdateChoice { Update, Continue, Cancel }
|
||||||
|
|
||||||
|
public partial class SelfUpdatePromptWindow : Window
|
||||||
|
{
|
||||||
|
public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
|
||||||
|
|
||||||
|
public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowProgress(string text)
|
||||||
|
{
|
||||||
|
ProgressText.Text = text;
|
||||||
|
ProgressText.Visibility = Visibility.Visible;
|
||||||
|
UpdateBtn.IsEnabled = false;
|
||||||
|
ContinueBtn.IsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateBtn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Choice = SelfUpdateChoice.Update;
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ContinueBtn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Choice = SelfUpdateChoice.Continue;
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelBtn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Choice = SelfUpdateChoice.Cancel;
|
||||||
|
DialogResult = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Installer.Steps;
|
using ClaudeDo.Installer.Steps;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -50,7 +51,12 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_uninstallRunner = uninstallRunner;
|
_uninstallRunner = uninstallRunner;
|
||||||
_selectedPage = Pages.FirstOrDefault();
|
_selectedPage = Pages.FirstOrDefault();
|
||||||
|
|
||||||
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
|
var label = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
|
||||||
|
if (!string.IsNullOrEmpty(context.LatestVersion))
|
||||||
|
label += $" Latest: {context.LatestVersion}";
|
||||||
|
if (context.LatestTagUnparseable)
|
||||||
|
label += " (pre-release tag — auto-update disabled)";
|
||||||
|
VersionLabel = label;
|
||||||
|
|
||||||
_ = LoadAllAsync();
|
_ = LoadAllAsync();
|
||||||
}
|
}
|
||||||
@@ -98,8 +104,39 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
};
|
};
|
||||||
uiCfg.Save();
|
uiCfg.Save();
|
||||||
|
|
||||||
StatusMessage = "Settings saved.";
|
|
||||||
IsStatusError = false;
|
IsStatusError = false;
|
||||||
|
StatusMessage = "Settings saved.";
|
||||||
|
|
||||||
|
// Worker reads its config at process start, so changes only take effect after a restart.
|
||||||
|
var restart = MessageBox.Show(
|
||||||
|
"Restart the worker service now so the new settings take effect?",
|
||||||
|
"Restart Worker",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question);
|
||||||
|
|
||||||
|
if (restart != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
StatusMessage = "Settings saved. Restart the worker service manually to apply.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||||
|
var stop = await _stopService.ExecuteAsync(_context, progress, CancellationToken.None);
|
||||||
|
if (!stop.Success)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Saved, but worker stop failed: {stop.ErrorMessage}";
|
||||||
|
IsStatusError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var start = await _startService.ExecuteAsync(_context, progress, CancellationToken.None);
|
||||||
|
if (!start.Success)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Saved, but worker start failed: {start.ErrorMessage}";
|
||||||
|
IsStatusError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusMessage = "Settings saved. Worker restarted.";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public static class ChecksumVerifier
|
public static class ChecksumVerifier
|
||||||
{
|
{
|
||||||
8
src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
Normal file
8
src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ using System.IO;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public sealed class ReleaseClient : IReleaseClient
|
public sealed class ReleaseClient : IReleaseClient
|
||||||
{
|
{
|
||||||
15
src/ClaudeDo.Releases/SelfUpdateResult.cs
Normal file
15
src/ClaudeDo.Releases/SelfUpdateResult.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
|
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
|
||||||
|
|
||||||
|
public enum SelfUpdateDecisionKind
|
||||||
|
{
|
||||||
|
NoUpdate,
|
||||||
|
UpdateAvailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SelfUpdateDecision(
|
||||||
|
SelfUpdateDecisionKind Kind,
|
||||||
|
string? LatestVersion = null,
|
||||||
|
ReleaseAsset? InstallerAsset = null,
|
||||||
|
ReleaseAsset? ChecksumsAsset = null);
|
||||||
126
src/ClaudeDo.Releases/SelfUpdater.cs
Normal file
126
src/ClaudeDo.Releases/SelfUpdater.cs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
|
public static partial class SelfUpdater
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex InstallerAssetRegex();
|
||||||
|
|
||||||
|
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> assets)
|
||||||
|
{
|
||||||
|
foreach (var asset in assets)
|
||||||
|
{
|
||||||
|
var m = InstallerAssetRegex().Match(asset.Name);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return new InstallerAssetMatch(asset, m.Groups["version"].Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<SelfUpdateDecision> DecideUpdateAsync(
|
||||||
|
IReleaseClient releases,
|
||||||
|
string currentVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
GiteaRelease? release;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
release = await releases.GetLatestReleaseAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (release is null)
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
|
||||||
|
var match = FindInstallerAsset(release.Assets);
|
||||||
|
if (match is null)
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
|
||||||
|
var cmp = VersionComparer.Compare(match.Version, currentVersion);
|
||||||
|
if (!cmp.IsNewer)
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
|
||||||
|
var checksums = release.Assets.FirstOrDefault(
|
||||||
|
a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return new SelfUpdateDecision(
|
||||||
|
SelfUpdateDecisionKind.UpdateAvailable,
|
||||||
|
LatestVersion: match.Version,
|
||||||
|
InstallerAsset: match.Asset,
|
||||||
|
ChecksumsAsset: checksums);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> HandleReplaceSelfAsync(
|
||||||
|
string oldPath,
|
||||||
|
string currentExePath,
|
||||||
|
Func<string, bool> launchProcess,
|
||||||
|
int maxWaitMs = 5000)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(oldPath))
|
||||||
|
{
|
||||||
|
File.Delete(oldPath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(oldPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(currentExePath, oldPath, overwrite: false);
|
||||||
|
return launchProcess(oldPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> DownloadAndVerifyAsync(
|
||||||
|
IReleaseClient releases,
|
||||||
|
ReleaseAsset installerAsset,
|
||||||
|
ReleaseAsset checksumsAsset,
|
||||||
|
string tempDir,
|
||||||
|
IProgress<long> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var installerPath = Path.Combine(tempDir, installerAsset.Name);
|
||||||
|
var checksumsPath = Path.Combine(tempDir, "checksums.txt");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct);
|
||||||
|
await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress<long>(_ => { }), ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct);
|
||||||
|
var map = ChecksumVerifier.ParseChecksumsFile(checksumsText);
|
||||||
|
if (!map.TryGetValue(installerAsset.Name, out var expected))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/ClaudeDo.Releases/VersionComparer.cs
Normal file
18
src/ClaudeDo.Releases/VersionComparer.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
|
public readonly record struct VersionCompareResult(bool IsNewer, bool Unparseable);
|
||||||
|
|
||||||
|
public static class VersionComparer
|
||||||
|
{
|
||||||
|
public static VersionCompareResult Compare(string latest, string current)
|
||||||
|
{
|
||||||
|
var latestTrimmed = (latest ?? "").TrimStart('v', 'V');
|
||||||
|
var currentTrimmed = (current ?? "").TrimStart('v', 'V');
|
||||||
|
|
||||||
|
var unparseable = !Version.TryParse(latestTrimmed, out var lv)
|
||||||
|
| !Version.TryParse(currentTrimmed, out var cv);
|
||||||
|
|
||||||
|
if (unparseable) return new VersionCompareResult(false, true);
|
||||||
|
return new VersionCompareResult(lv > cv, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/ClaudeDo.Ui/Assets/Fonts/InterTight[wght].ttf
Normal file
BIN
src/ClaudeDo.Ui/Assets/Fonts/InterTight[wght].ttf
Normal file
Binary file not shown.
BIN
src/ClaudeDo.Ui/Assets/Fonts/JetBrainsMono[wght].ttf
Normal file
BIN
src/ClaudeDo.Ui/Assets/Fonts/JetBrainsMono[wght].ttf
Normal file
Binary file not shown.
93
src/ClaudeDo.Ui/Assets/Fonts/OFL-InterTight.txt
Normal file
93
src/ClaudeDo.Ui/Assets/Fonts/OFL-InterTight.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Inter Project Authors (https://github.com/rsms/inter-tight)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
src/ClaudeDo.Ui/Assets/Fonts/OFL-JetBrainsMono.txt
Normal file
93
src/ClaudeDo.Ui/Assets/Fonts/OFL-JetBrainsMono.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -17,6 +17,8 @@ MVVM with CommunityToolkit.Mvvm source generators:
|
|||||||
- **TaskEditorView** — Modal dialog for task create/edit
|
- **TaskEditorView** — Modal dialog for task create/edit
|
||||||
- **ListEditorView** — Modal dialog for list create/edit
|
- **ListEditorView** — Modal dialog for list create/edit
|
||||||
- **StatusBarView** — Connection status indicator, active task display
|
- **StatusBarView** — Connection status indicator, active task display
|
||||||
|
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row.
|
||||||
|
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running.
|
||||||
|
|
||||||
All views use compiled bindings (`x:DataType`).
|
All views use compiled bindings (`x:DataType`).
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ All views use compiled bindings (`x:DataType`).
|
|||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated
|
- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated
|
||||||
|
|
||||||
## Converters
|
## Converters
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -9,6 +10,12 @@
|
|||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -18,4 +25,11 @@
|
|||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
|
||||||
|
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
|
||||||
|
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
|
||||||
|
<AvaloniaResource Include="..\ClaudeDo.App\Assets\ClaudeTask.ico" Link="Assets\ClaudeTask.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
15
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal file
15
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToDraftOpacityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static BoolToDraftOpacityConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is true ? 0.7 : 1.0;
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
16
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal file
16
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToItalicConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static BoolToItalicConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
24
src/ClaudeDo.Ui/Converters/DotBrushConverter.cs
Normal file
24
src/ClaudeDo.Ui/Converters/DotBrushConverter.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public class DotBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static DotBrushConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var key = value?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(key)) key = "Moss";
|
||||||
|
var resourceKey = $"{key}Brush";
|
||||||
|
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
|
||||||
|
return res as IBrush;
|
||||||
|
return Brushes.Transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
26
src/ClaudeDo.Ui/Converters/EqStatusConverter.cs
Normal file
26
src/ClaudeDo.Ui/Converters/EqStatusConverter.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when the bound TaskStatus equals the ConverterParameter string.
|
||||||
|
/// Usage: Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||||
|
/// </summary>
|
||||||
|
public class EqStatusConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static EqStatusConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is TaskStatus status && parameter is string name &&
|
||||||
|
Enum.TryParse<TaskStatus>(name, ignoreCase: true, out var target))
|
||||||
|
return status == target;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
28
src/ClaudeDo.Ui/Converters/IconKeyConverter.cs
Normal file
28
src/ClaudeDo.Ui/Converters/IconKeyConverter.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an icon key string (e.g. "Sun") to the matching StreamGeometry resource "Icon.Sun".
|
||||||
|
/// </summary>
|
||||||
|
public class IconKeyConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static IconKeyConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not string key || string.IsNullOrEmpty(key))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var resourceKey = $"Icon.{key}";
|
||||||
|
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
|
||||||
|
return res as StreamGeometry;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
15
src/ClaudeDo.Ui/Converters/NotNullToBoolConverter.cs
Normal file
15
src/ClaudeDo.Ui/Converters/NotNullToBoolConverter.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public class NotNullToBoolConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static NotNullToBoolConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is not null;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
16
src/ClaudeDo.Ui/Converters/StrikeIfTrueConverter.cs
Normal file
16
src/ClaudeDo.Ui/Converters/StrikeIfTrueConverter.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public class StrikeIfTrueConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static StrikeIfTrueConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is true ? TextDecorations.Strikethrough : null;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
15
src/ClaudeDo.Ui/Converters/UpperCaseConverter.cs
Normal file
15
src/ClaudeDo.Ui/Converters/UpperCaseConverter.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public class UpperCaseConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static UpperCaseConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value?.ToString()?.ToUpperInvariant();
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
45
src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs
Normal file
45
src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
901
src/ClaudeDo.Ui/Design/IslandStyles.axaml
Normal file
901
src/ClaudeDo.Ui/Design/IslandStyles.axaml
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
<!--
|
||||||
|
ClaudeDo component styles for Avalonia.
|
||||||
|
Depends on Tokens.axaml being merged first.
|
||||||
|
|
||||||
|
How to use each style:
|
||||||
|
<Border Classes="island"> — floating island container
|
||||||
|
<Border Classes="chip running"> — status chip
|
||||||
|
<Button Classes="icon-btn"> — 24×24 icon button
|
||||||
|
<Button Classes="btn primary"> — rounded-rect button
|
||||||
|
<TextBlock Classes="eyebrow"> — uppercase mono label
|
||||||
|
<Border Classes="agent-strip running"> — agent status strip
|
||||||
|
<Border Classes="terminal"> — terminal/log window
|
||||||
|
-->
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ICON GEOMETRY RESOURCES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Styles.Resources>
|
||||||
|
<!-- Window control icons — filled geometries (PathIcon fills, not strokes) -->
|
||||||
|
<StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry>
|
||||||
|
<!-- Brand check glyph — filled rounded square with inset tick -->
|
||||||
|
<StreamGeometry x:Key="Icon.BrandCheck">M3 3 H21 V21 H3 Z M6 12 L7 11 L10 14 L17 7 L18 8 L10 16 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Icons — central icon library (Phase B) -->
|
||||||
|
<!-- All d-strings sourced from icons.jsx -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Icon.Sun -->
|
||||||
|
<StreamGeometry x:Key="Icon.Sun">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Activity (pulse waveform) -->
|
||||||
|
<StreamGeometry x:Key="Icon.Activity">M3 12h4l2-6 4 12 2-8 2 2h4</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Star -->
|
||||||
|
<StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Calendar -->
|
||||||
|
<StreamGeometry x:Key="Icon.Calendar">M3.5 5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z M3.5 10h17M8 3v4M16 3v4</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Eye -->
|
||||||
|
<StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Inbox -->
|
||||||
|
<StreamGeometry x:Key="Icon.Inbox">M3 13h5l1 2h6l1-2h5M3 13l3-8h12l3 8v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Folder -->
|
||||||
|
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Search -->
|
||||||
|
<StreamGeometry x:Key="Icon.Search">M11 4a7 7 0 1 0 0 14A7 7 0 0 0 11 4z M20 20l-3.5-3.5</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Plus -->
|
||||||
|
<StreamGeometry x:Key="Icon.Plus">M12 5v14M5 12h14</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.MoreHorizontal (three filled dots) — uses fill so rendered via PathIcon with fill brush -->
|
||||||
|
<StreamGeometry x:Key="Icon.MoreHorizontal">M5 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M12 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M19 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.GitBranch -->
|
||||||
|
<StreamGeometry x:Key="Icon.GitBranch">M6 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 19a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M18 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 7v10M6 13c0-4 12-2 12-4</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Copy -->
|
||||||
|
<StreamGeometry x:Key="Icon.Copy">M8 8h12a1.5 1.5 0 0 1 1.5 1.5v12A1.5 1.5 0 0 1 20 23H8a1.5 1.5 0 0 1-1.5-1.5v-12A1.5 1.5 0 0 1 8 8z M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Trash -->
|
||||||
|
<StreamGeometry x:Key="Icon.Trash">M4 7h16M10 11v6M14 11v6M5 7l1 13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-13M9 7V4h6v3</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Sort -->
|
||||||
|
<StreamGeometry x:Key="Icon.Sort">M7 4v16M7 20l-3-3M7 4l-3 3M14 8h7M14 12h5M14 16h3</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.X -->
|
||||||
|
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Check -->
|
||||||
|
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
||||||
|
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Settings (gear) -->
|
||||||
|
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Badge brushes -->
|
||||||
|
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
|
||||||
|
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
|
||||||
|
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
|
||||||
|
|
||||||
|
</Styles.Resources>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TITLE BAR CONTROLS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<Style Selector="Button.title-ctrl">
|
||||||
|
<Setter Property="Width" Value="36" />
|
||||||
|
<Setter Property="Height" Value="36" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.title-ctrl:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.title-ctrl.close:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BloodBrush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ISLAND -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.island">
|
||||||
|
<Setter Property="Background" Value="{StaticResource IslandBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="#0DFFFFFF" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource IslandCornerRadius}" />
|
||||||
|
<Setter Property="BoxShadow" Value="{StaticResource IslandShadow}" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Island header separator (apply on the header Border inside an island) -->
|
||||||
|
<Style Selector="Border.island-header">
|
||||||
|
<Setter Property="Padding" Value="{StaticResource IslandHeaderPadding}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CHIPS / BADGES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.chip">
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ChipCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="8,3" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip > TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Status variants — tint background 12% alpha of the status hue -->
|
||||||
|
<Style Selector="Border.chip.running">
|
||||||
|
<Setter Property="Background" Value="#1F7C9166" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4C7C9166" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.running > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource StatusRunningBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.chip.review">
|
||||||
|
<Setter Property="Background" Value="#1FD4A574" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CD4A574" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.review > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource StatusReviewBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.chip.error">
|
||||||
|
<Setter Property="Background" Value="#1FC87060" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CC87060" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.error > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource StatusErrorBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- queued → Sage (#8B9D7A) -->
|
||||||
|
<Style Selector="Border.chip.queued">
|
||||||
|
<Setter Property="Background" Value="#1F8B9D7A" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4C8B9D7A" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.queued > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- idle → TextMute (#6B7973) -->
|
||||||
|
<Style Selector="Border.chip.idle">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip.idle > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- BUTTONS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Button.btn">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ButtonCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="10,6" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||||
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.btn:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.btn.primary">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Icon button: 24×24 square with hover surface -->
|
||||||
|
<Style Selector="Button.icon-btn">
|
||||||
|
<Setter Property="Width" Value="24" />
|
||||||
|
<Setter Property="Height" Value="24" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- INPUTS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="TextBox.search">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource InputCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.search:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="BoxShadow" Value="0 0 0 3 #387C9166" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- FLAT BUTTON — replaces the entire Button template with a -->
|
||||||
|
<!-- bare ContentPresenter so no Fluent chrome (bg / hover / -->
|
||||||
|
<!-- pressed) can render. Used to wrap TaskRowView for clicks. -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Button.flat">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<ContentPresenter Name="PART_ContentPresenter"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
|
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.flat:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.flat:pressed /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.flat:focus /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TASK ROW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.task-row">
|
||||||
|
<Setter Property="Padding" Value="12,10" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,6" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.10"/>
|
||||||
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.10"/>
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row:pointerover">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row.selected">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
||||||
|
<Style Selector="Ellipse.task-check">
|
||||||
|
<Setter Property="Width" Value="18" />
|
||||||
|
<Setter Property="Height" Value="18" />
|
||||||
|
<Setter Property="StrokeThickness" Value="1.5" />
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
<Setter Property="Fill" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.task-check.done">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Status dot pulse (applied when running) -->
|
||||||
|
<Style Selector="Ellipse.status-pulse">
|
||||||
|
<Style.Animations>
|
||||||
|
<Animation Duration="0:0:1.2" IterationCount="INFINITE" Easing="CubicEaseInOut">
|
||||||
|
<KeyFrame Cue="0%"><Setter Property="Opacity" Value="0.4"/></KeyFrame>
|
||||||
|
<KeyFrame Cue="50%"><Setter Property="Opacity" Value="1.0"/></KeyFrame>
|
||||||
|
<KeyFrame Cue="100%"><Setter Property="Opacity" Value="0.4"/></KeyFrame>
|
||||||
|
</Animation>
|
||||||
|
</Style.Animations>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- AGENT STRIP -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.agent-strip">
|
||||||
|
<Setter Property="Padding" Value="12,10" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.agent-strip.running">
|
||||||
|
<Setter Property="Background" Value="#147C9166" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4C7C9166" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.agent-strip.review">
|
||||||
|
<Setter Property="Background" Value="#14D4A574" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CD4A574" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.agent-strip.error">
|
||||||
|
<Setter Property="Background" Value="#14C87060" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4CC87060" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- queued → Sage tint -->
|
||||||
|
<Style Selector="Border.agent-strip.queued">
|
||||||
|
<Setter Property="Background" Value="#148B9D7A" />
|
||||||
|
<Setter Property="BorderBrush" Value="#4C8B9D7A" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- idle → neutral (same as base, explicit for clarity) -->
|
||||||
|
<Style Selector="Border.agent-strip.idle">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TERMINAL / LOG -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.terminal">
|
||||||
|
<Setter Property="Background" Value="#FF080C0B" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="12" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock[Tag=log-sys]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock[Tag=log-tool]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock[Tag=log-claude]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<!-- log-stdout: program output — dim text, same as base -->
|
||||||
|
<Style Selector="Border.terminal TextBlock[Tag=log-stdout]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock[Tag=log-stderr]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.terminal TextBlock[Tag=log-done]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
<!-- log-msg: generic informational message — slightly brighter than sys -->
|
||||||
|
<Style Selector="Border.terminal TextBlock[Tag=log-msg]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TERMINAL HEADER -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- traffic-light dot colors -->
|
||||||
|
<Style Selector="Ellipse.dot-red">
|
||||||
|
<Setter Property="Width" Value="8" />
|
||||||
|
<Setter Property="Height" Value="8" />
|
||||||
|
<Setter Property="Fill" Value="#5A2A26" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.dot-yellow">
|
||||||
|
<Setter Property="Width" Value="8" />
|
||||||
|
<Setter Property="Height" Value="8" />
|
||||||
|
<Setter Property="Fill" Value="#6A5A28" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.dot-green">
|
||||||
|
<Setter Property="Width" Value="8" />
|
||||||
|
<Setter Property="Height" Value="8" />
|
||||||
|
<Setter Property="Fill" Value="#2F4D2F" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- LIVE chip (pulsing dot + text) -->
|
||||||
|
<Style Selector="Border.live-chip">
|
||||||
|
<Setter Property="Padding" Value="5,2" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Setter Property="Background" Value="#267C9166" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.live-chip > StackPanel > TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="9" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="LetterSpacing" Value="1.2" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.live-chip > StackPanel > Ellipse">
|
||||||
|
<Setter Property="Width" Value="6" />
|
||||||
|
<Setter Property="Height" Value="6" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.live-chip.pulsing > StackPanel > Ellipse">
|
||||||
|
<Style.Animations>
|
||||||
|
<Animation Duration="0:0:1.4" IterationCount="INFINITE" Easing="CubicEaseInOut">
|
||||||
|
<KeyFrame Cue="0%"> <Setter Property="Opacity" Value="1.0"/></KeyFrame>
|
||||||
|
<KeyFrame Cue="50%"> <Setter Property="Opacity" Value="0.3"/></KeyFrame>
|
||||||
|
<KeyFrame Cue="100%"><Setter Property="Opacity" Value="1.0"/></KeyFrame>
|
||||||
|
</Animation>
|
||||||
|
</Style.Animations>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Terminal log-line timestamp column -->
|
||||||
|
<Style Selector="TextBlock.log-ts">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="Width" Value="60" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Top" />
|
||||||
|
<Setter Property="Margin" Value="0,0,4,0" />
|
||||||
|
</Style>
|
||||||
|
<!-- Kind marker column -->
|
||||||
|
<Style Selector="TextBlock.log-kind">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
<Setter Property="Width" Value="46" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Top" />
|
||||||
|
<Setter Property="Margin" Value="0,0,6,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.log-kind[Tag=log-tool]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.log-kind[Tag=log-claude]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.log-kind[Tag=log-stderr]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.log-kind[Tag=log-done]">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- WORKTREE MODAL STATUS BADGES -->
|
||||||
|
<!-- Tag="M" → peat, "A" → moss, "D" → blood, "?" → faint -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border[Tag=M]">
|
||||||
|
<Setter Property="Background" Value="#26A06040"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=M] > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource PeatBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=A]">
|
||||||
|
<Setter Property="Background" Value="#267C9166"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=A] > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MossBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=D]">
|
||||||
|
<Setter Property="Background" Value="#26C87060"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=D] > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=?]">
|
||||||
|
<Setter Property="Background" Value="#1A888888"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=?] > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- LIST NAV ITEM -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.list-item">
|
||||||
|
<Setter Property="Padding" Value="10,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.list-item:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.list-item.active">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
|
||||||
|
</Style>
|
||||||
|
<!-- Active item text / icon colors -->
|
||||||
|
<Style Selector="Border.list-item.active TextBlock.list-label">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.list-item.active PathIcon.list-icon">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Count badge — larger, high contrast, brighter when the row is active -->
|
||||||
|
<Style Selector="TextBlock.list-count">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Margin" Value="8,0,4,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.list-item.active TextBlock.list-count">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- LIST SECTION HEADER -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="TextBlock.list-section-label">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="9" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="Margin" Value="10,10,10,4" />
|
||||||
|
<Setter Property="LetterSpacing" Value="1.2" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SEARCH BOX WRAPPER -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.search-wrap">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="8,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.search-wrap:focus-within">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<!-- Borderless TextBox inside search-wrap -->
|
||||||
|
<Style Selector="Border.search-wrap TextBox.search-inner">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="4,7" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.search-wrap TextBox.search-inner /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="BoxShadow" Value="none" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- KBD CHIP -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.kbd">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Setter Property="Padding" Value="8,3" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.kbd > TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- NEW LIST BUTTON -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Button.new-list-btn">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.new-list-btn:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- FOOTER PROFILE ROW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.avatar-circle">
|
||||||
|
<Setter Property="Width" Value="28" />
|
||||||
|
<Setter Property="Height" Value="28" />
|
||||||
|
<Setter Property="CornerRadius" Value="14" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ADD-TASK ROW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.add-task">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Padding" Value="14,12" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.15" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.add-task:focus-within">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Plus circle inside the add-task row -->
|
||||||
|
<Style Selector="Border.add-task-plus">
|
||||||
|
<Setter Property="Width" Value="20" />
|
||||||
|
<Setter Property="Height" Value="20" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.add-task-plus > PathIcon">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Borderless TextBox inside the add-task row -->
|
||||||
|
<Style Selector="TextBox.add-task-input">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="MinHeight" Value="20" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.add-task-input /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="BoxShadow" Value="none" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- kbd-enter variant (no extra styles needed — base kbd works) -->
|
||||||
|
<Style Selector="Border.kbd.kbd-enter > TextBlock">
|
||||||
|
<Setter Property="LetterSpacing" Value="1.2" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- HEADER TOOLBAR icon-btn active state -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Button.icon-btn.active /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TASK ROW — extensions (C2) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Augment base task-row transitions to include Margin -->
|
||||||
|
<Style Selector="Border.task-row">
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||||
|
<ThicknessTransition Property="Margin" Duration="0:0:0.15" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Selected state: rely on the left accent bar from TaskRowView;
|
||||||
|
no heavy bg or perimeter border. -->
|
||||||
|
<Style Selector="Border.task-row.selected">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Left accent bar for selected row -->
|
||||||
|
<Style Selector="Border.task-row-accent">
|
||||||
|
<Setter Property="Width" Value="2" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="1" />
|
||||||
|
<Setter Property="Margin" Value="0,4" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Done state: dim the whole row and strike through the title -->
|
||||||
|
<Style Selector="Border.task-row.done">
|
||||||
|
<Setter Property="Opacity" Value="0.45" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row.done TextBlock.task-title">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="TextDecorations" Value="Strikethrough" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CHIP EXTENSIONS -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Slightly slimmer chips inside task rows -->
|
||||||
|
<Style Selector="Border.task-row Border.chip">
|
||||||
|
<Setter Property="Padding" Value="6,2" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row Border.chip > StackPanel > TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Tag chip: faint text -->
|
||||||
|
<Style Selector="Border.chip.chip-tag > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Diff chip add/del coloring -->
|
||||||
|
<Style Selector="TextBlock.diff-add">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.diff-del">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- STAR BUTTON -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Button.star-btn">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="Opacity" Value="0.6" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.star-btn.on">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource PeatBrush}" />
|
||||||
|
<Setter Property="Opacity" Value="1.0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.star-btn.on /template/ ContentPresenter">
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource PeatBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- LIVE-TAIL PREVIEW ROW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.task-live-tail">
|
||||||
|
<Setter Property="Background" Value="#FF080C0B" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="5" />
|
||||||
|
<Setter Property="Padding" Value="8,4" />
|
||||||
|
<Setter Property="Margin" Value="0,2,0,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-live-tail TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- DIFF METER -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Outer track (full width, line-bright bg) -->
|
||||||
|
<Style Selector="Border.diff-meter-track">
|
||||||
|
<Setter Property="Height" Value="4" />
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource LineBrightBrush}" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
</Style>
|
||||||
|
<!-- Filled portion (moss; width set via ScaleTransform or Width binding in view) -->
|
||||||
|
<Style Selector="Rectangle.diff-meter-fill">
|
||||||
|
<Setter Property="Height" Value="4" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource MossBrightBrush}" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SUBTASK ROW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.subtask-row">
|
||||||
|
<Setter Property="Padding" Value="8,5" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.10"/>
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.subtask-row:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
|
||||||
|
<Setter Property="Opacity" Value="0.5" />
|
||||||
|
<Setter Property="TextDecorations" Value="Strikethrough" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SECTION LABELS (OVERDUE / TASKS / COMPLETED) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="TextBlock.section-label">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="LetterSpacing" Value="1.4" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.section-label.overdue">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- PLANNING / DRAFT BADGES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="3"/>
|
||||||
|
<Setter Property="Padding" Value="4,1"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge > TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="9"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.draft">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planned">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</Styles>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user