Compare commits
142 Commits
v1.2.0
...
a135485339
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
README.md
37
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
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.
|
||||||
@@ -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.
|
||||||
@@ -2,33 +2,24 @@
|
|||||||
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"/>
|
||||||
|
|
||||||
<!-- 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 +28,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,10 +1,11 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
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;
|
||||||
@@ -28,8 +29,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
|
||||||
@@ -75,30 +76,27 @@ sealed class Program
|
|||||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
sc.AddTransient<ListEditorViewModel>();
|
sc.AddTransient<WorktreeModalViewModel>();
|
||||||
sc.AddTransient<TaskEditorViewModel>();
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
sc.AddSingleton<StatusBarViewModel>();
|
sc.AddTransient<MergeModalViewModel>();
|
||||||
sc.AddSingleton<TaskDetailViewModel>();
|
sc.AddTransient<ListSettingsModalViewModel>();
|
||||||
sc.AddSingleton<TaskListViewModel>(sp =>
|
|
||||||
{
|
// Islands shell VMs
|
||||||
var dbFactory = sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>();
|
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||||
var worker = sp.GetRequiredService<WorkerClient>();
|
new ListsIslandViewModel(
|
||||||
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
return new TaskListViewModel(
|
sp,
|
||||||
dbFactory, worker,
|
sp.GetRequiredService<WorkerClient>()));
|
||||||
() => sp.GetRequiredService<TaskEditorViewModel>(),
|
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||||
msg => statusBar.ShowMessage(msg));
|
new TasksIslandViewModel(
|
||||||
});
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sc.AddSingleton<MainWindowViewModel>(sp =>
|
sp.GetRequiredService<WorkerClient>()));
|
||||||
{
|
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||||
return new MainWindowViewModel(
|
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)
|
||||||
{
|
{
|
||||||
@@ -73,5 +75,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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ 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.HasOne(t => t.List)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.WithMany(l => l.Tasks)
|
||||||
@@ -71,5 +75,6 @@ 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("model");
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("notes");
|
||||||
|
|
||||||
b.Property<string>("Result")
|
b.Property<string>("Result")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("result");
|
.HasColumnName("result");
|
||||||
@@ -191,6 +281,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");
|
||||||
@@ -217,6 +313,9 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ 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; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
|
|||||||
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
|
||||||
@@ -175,7 +234,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ 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 +31,26 @@ 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 newer = IsNewer(latestVersion, manifest.Version, out var 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>
|
/// <summary>
|
||||||
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
|
/// 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
|
/// 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.
|
/// treated as "not newer" — the user drops into Config mode with no update offered, but
|
||||||
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
|
/// <paramref name="unparseable"/> is set so the UI can surface a hint.
|
||||||
/// If the project starts shipping pre-release tags, revisit this.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool IsNewer(string latest, string current)
|
private static bool IsNewer(string latest, string current, out bool unparseable)
|
||||||
{
|
{
|
||||||
if (!Version.TryParse(latest, out var lv)) return false;
|
unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv);
|
||||||
if (!Version.TryParse(current, out var cv)) return false;
|
if (unparseable) return false;
|
||||||
return lv > cv;
|
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,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)
|
||||||
|
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
|
||||||
|
|
||||||
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
|
|
||||||
if (exit == 1056)
|
if (exit == 1056)
|
||||||
{
|
|
||||||
progress.Report("Service was already running.");
|
progress.Report("Service was already running.");
|
||||||
return StepResult.Ok();
|
|
||||||
|
// 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($"sc.exe start failed with exit code {exit}");
|
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)}");
|
||||||
|
|||||||
@@ -50,7 +50,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 +103,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]
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,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>
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
866
src/ClaudeDo.Ui/Design/IslandStyles.axaml
Normal file
866
src/ClaudeDo.Ui/Design/IslandStyles.axaml
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
<!--
|
||||||
|
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>
|
||||||
|
|
||||||
|
</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>
|
||||||
|
|
||||||
|
</Styles>
|
||||||
201
src/ClaudeDo.Ui/Design/Tokens.axaml
Normal file
201
src/ClaudeDo.Ui/Design/Tokens.axaml
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<!--
|
||||||
|
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"
|
||||||
|
xmlns:sys="clr-namespace:System;assembly=netstandard">
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 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}" />
|
||||||
|
|
||||||
|
<!-- MossBrush at #7C9166 (design-token reference value = AccentColor) -->
|
||||||
|
<SolidColorBrush x:Key="MossBrush" Color="{StaticResource AccentColor}" />
|
||||||
|
<SolidColorBrush x:Key="MossDarkBrush" 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>
|
||||||
|
|
||||||
|
<!-- Radius doubles for use in numeric contexts -->
|
||||||
|
<x:Double x:Key="IslandRadius">14</x:Double>
|
||||||
|
<x:Double x:Key="ModalRadius">12</x:Double>
|
||||||
|
<x:Double x:Key="ChipRadius">10</x:Double>
|
||||||
|
<x:Double x:Key="RowRadius">8</x:Double>
|
||||||
|
<x:Double x:Key="ButtonRadius">6</x:Double>
|
||||||
|
|
||||||
|
<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">avares://ClaudeDo.Ui/Assets/Fonts/#Inter Tight, Inter, Segoe UI, -apple-system, sans-serif</FontFamily>
|
||||||
|
<FontFamily x:Key="MonoFont">avares://ClaudeDo.Ui/Assets/Fonts/#JetBrains Mono, IBM Plex Mono, Cascadia Mono, Consolas, monospace</FontFamily>
|
||||||
|
|
||||||
|
<!-- Aliases matching plan-required key names -->
|
||||||
|
<FontFamily x:Key="SansFamily">avares://ClaudeDo.Ui/Assets/Fonts/#Inter Tight, Inter, Segoe UI, -apple-system, sans-serif</FontFamily>
|
||||||
|
<FontFamily x:Key="MonoFamily">avares://ClaudeDo.Ui/Assets/Fonts/#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" />
|
||||||
|
</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 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<sys:TimeSpan x:Key="MotionFast">0:0:0.12</sys:TimeSpan>
|
||||||
|
<sys:TimeSpan x:Key="MotionBase">0:0:0.18</sys:TimeSpan>
|
||||||
|
<sys:TimeSpan x:Key="MotionSlow">0:0:0.30</sys:TimeSpan>
|
||||||
|
|
||||||
|
<!-- Standard easing: cubic-bezier(0.4, 0, 0.2, 1) — equivalent to Avalonia's CubicEaseOut for most UI -->
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -6,6 +6,7 @@ namespace ClaudeDo.Ui.Helpers;
|
|||||||
public class StreamLineFormatter
|
public class StreamLineFormatter
|
||||||
{
|
{
|
||||||
private const int MaxLength = 50_000;
|
private const int MaxLength = 50_000;
|
||||||
|
private const int MaxArgChars = 120;
|
||||||
|
|
||||||
public string? FormatLine(string line)
|
public string? FormatLine(string line)
|
||||||
{
|
{
|
||||||
@@ -22,75 +23,295 @@ public class StreamLineFormatter
|
|||||||
using (doc)
|
using (doc)
|
||||||
{
|
{
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
return null;
|
||||||
if (!root.TryGetProperty("type", out var typeProp))
|
if (!root.TryGetProperty("type", out var typeProp))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var type = typeProp.GetString();
|
return typeProp.GetString() switch
|
||||||
|
|
||||||
switch (type)
|
|
||||||
{
|
{
|
||||||
case "stream_event":
|
"system" => FormatSystem(root),
|
||||||
return FormatStreamEvent(root);
|
"assistant" => FormatAssistant(root),
|
||||||
|
"user" => FormatUser(root),
|
||||||
case "result":
|
"result" => FormatResult(root),
|
||||||
if (root.TryGetProperty("result", out var resultProp))
|
_ => null,
|
||||||
return $"\n--- Result ---\n{resultProp.GetString()}\n";
|
};
|
||||||
return null;
|
|
||||||
|
|
||||||
case "system":
|
|
||||||
if (root.TryGetProperty("subtype", out var subtypeProp) &&
|
|
||||||
subtypeProp.GetString() == "api_retry")
|
|
||||||
return "\n[Retrying API call...]\n";
|
|
||||||
return null;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? FormatStreamEvent(JsonElement root)
|
private static string? FormatSystem(JsonElement root)
|
||||||
{
|
{
|
||||||
if (!root.TryGetProperty("event", out var ev))
|
if (!root.TryGetProperty("subtype", out var subtypeProp))
|
||||||
return null;
|
|
||||||
if (!ev.TryGetProperty("type", out var evTypeProp))
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var evType = evTypeProp.GetString();
|
var subtype = subtypeProp.GetString();
|
||||||
|
switch (subtype)
|
||||||
switch (evType)
|
|
||||||
{
|
{
|
||||||
case "content_block_delta":
|
case "api_retry":
|
||||||
if (!ev.TryGetProperty("delta", out var delta))
|
return "[Retrying API call...]\n";
|
||||||
return null;
|
|
||||||
if (!delta.TryGetProperty("type", out var deltaTypeProp))
|
case "init":
|
||||||
return null;
|
{
|
||||||
var deltaType = deltaTypeProp.GetString();
|
var sessionId = root.TryGetProperty("session_id", out var sid)
|
||||||
if (deltaType == "text_delta")
|
? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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":
|
||||||
|
sb.Append(FormatToolUse(block));
|
||||||
|
sb.Append('\n');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
return delta.TryGetProperty("text", out var textProp)
|
if (sb.Length > 0) sb.Append('\n');
|
||||||
? textProp.GetString()
|
sb.Append(t.GetString());
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
return null; // input_json_delta and others → skip
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
case "content_block_stop":
|
return "";
|
||||||
return "\n";
|
}
|
||||||
|
|
||||||
case "content_block_start":
|
private static string FirstNonEmptyLine(string s)
|
||||||
if (!ev.TryGetProperty("content_block", out var cb))
|
{
|
||||||
return null;
|
if (string.IsNullOrEmpty(s)) return "";
|
||||||
if (cb.TryGetProperty("type", out var cbTypeProp) &&
|
foreach (var raw in s.Split('\n'))
|
||||||
cbTypeProp.GetString() == "tool_use" &&
|
{
|
||||||
cb.TryGetProperty("name", out var nameProp))
|
var line = raw.TrimEnd('\r').Trim();
|
||||||
return $"\n[Tool: {nameProp.GetString()}]\n";
|
if (line.Length > 0) return line;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatResult(JsonElement root)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("result", out var resultProp))
|
||||||
|
return $"\n--- Result ---\n{resultProp.GetString()}\n";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Truncate(GetStr(input, "url"), MaxArgChars);
|
||||||
|
|
||||||
|
case "WebSearch":
|
||||||
|
{
|
||||||
|
var q = GetStr(input, "query");
|
||||||
|
return string.IsNullOrEmpty(q) ? null : $"\"{Truncate(q, MaxArgChars)}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
case "TodoWrite":
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null; // message_start, message_delta, etc.
|
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] + "…";
|
||||||
|
}
|
||||||
|
|
||||||
public string FormatFile(string filePath)
|
public string FormatFile(string filePath)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string>? RunNowRequestedEvent;
|
public event Action<string>? RunNowRequestedEvent;
|
||||||
|
public event Action<string>? ListUpdatedEvent;
|
||||||
|
|
||||||
public WorkerClient(string signalRUrl)
|
public WorkerClient(string signalRUrl)
|
||||||
{
|
{
|
||||||
@@ -110,6 +111,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_hub.On<string>("ListUpdated", listId =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => ListUpdatedEvent?.Invoke(listId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StartAsync()
|
public Task StartAsync()
|
||||||
@@ -169,6 +175,34 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
await _hub.InvokeAsync("RunNow", taskId);
|
await _hub.InvokeAsync("RunNow", taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<MergeResultDto>(
|
||||||
|
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task CancelTaskAsync(string taskId)
|
public async Task CancelTaskAsync(string taskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("CancelTask", taskId);
|
await _hub.InvokeAsync("CancelTask", taskId);
|
||||||
@@ -197,6 +231,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
await _hub.InvokeAsync("RefreshAgents");
|
await _hub.InvokeAsync("RefreshAgents");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SeedActiveTasksAsync()
|
private async Task SeedActiveTasksAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -226,6 +272,67 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
await _hub.DisposeAsync();
|
await _hub.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AppSettingsDto?> GetAppSettingsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<AppSettingsDto>("GetAppSettings");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("UpdateAppSettings", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateListAsync(UpdateListDto dto)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("UpdateList", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateListConfigAsync(UpdateListConfigDto dto)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("UpdateListConfig", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ListConfigDto?> GetListConfigAsync(string listId)
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DTOs for deserializing hub responses
|
// DTOs for deserializing hub responses
|
||||||
private sealed class ActiveTaskDto
|
private sealed class ActiveTaskDto
|
||||||
{
|
{
|
||||||
@@ -234,3 +341,23 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
public DateTime StartedAt { get; set; }
|
public DateTime StartedAt { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AppSettingsDto(
|
||||||
|
string DefaultClaudeInstructions,
|
||||||
|
string DefaultModel,
|
||||||
|
int DefaultMaxTurns,
|
||||||
|
string DefaultPermissionMode,
|
||||||
|
string WorktreeStrategy,
|
||||||
|
string? CentralWorktreeRoot,
|
||||||
|
bool WorktreeAutoCleanupEnabled,
|
||||||
|
int WorktreeAutoCleanupDays);
|
||||||
|
|
||||||
|
public sealed record WorktreeCleanupDto(int Removed);
|
||||||
|
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||||
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||||
|
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||||
|
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||||
|
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
|
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|||||||
654
src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
Normal file
654
src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Ui.Helpers;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly WorkerClient _worker;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
// Current task row (set by IslandsShellViewModel via Bind)
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||||||
|
private TaskRowViewModel? _task;
|
||||||
|
|
||||||
|
// Editable fields
|
||||||
|
[ObservableProperty] private string _editableTitle = "";
|
||||||
|
[ObservableProperty] private string _notes = "";
|
||||||
|
[ObservableProperty] private string _promptInput = "";
|
||||||
|
|
||||||
|
// Short task-id badge, e.g. "#T1A"
|
||||||
|
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||||
|
|
||||||
|
// Agent strip fields
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||||||
|
private string _agentStatusLabel = "Idle";
|
||||||
|
public bool IsRunning => AgentStatusLabel == "Running";
|
||||||
|
public bool IsDone => AgentStatusLabel == "Done";
|
||||||
|
public bool IsFailed => AgentStatusLabel == "Failed";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
|
||||||
|
private bool _showFailedActions;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
|
private string? _latestRunSessionId;
|
||||||
|
|
||||||
|
partial void OnAgentStatusLabelChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
|
OnPropertyChanged(nameof(IsDone));
|
||||||
|
OnPropertyChanged(nameof(IsFailed));
|
||||||
|
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||||
|
ShowFailedActions = value == "Failed";
|
||||||
|
}
|
||||||
|
[ObservableProperty] private string? _model;
|
||||||
|
|
||||||
|
// Agent settings overrides
|
||||||
|
[ObservableProperty] private string _taskModelSelection = "(inherit)";
|
||||||
|
[ObservableProperty] private string _taskSystemPrompt = "";
|
||||||
|
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _effectiveModelHint = "";
|
||||||
|
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||||
|
[ObservableProperty] private string _effectiveAgentHint = "";
|
||||||
|
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new()
|
||||||
|
{
|
||||||
|
"(inherit)", "sonnet", "opus", "haiku",
|
||||||
|
};
|
||||||
|
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
||||||
|
|
||||||
|
private bool _suppressAgentSave;
|
||||||
|
private CancellationTokenSource? _agentSaveCts;
|
||||||
|
|
||||||
|
public bool IsAgentSectionEnabled => !IsRunning;
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _worktreePath;
|
||||||
|
[ObservableProperty] private string? _worktreeBaseCommit;
|
||||||
|
[ObservableProperty] private string? _worktreeStateLabel;
|
||||||
|
[ObservableProperty] private string? _branchLine;
|
||||||
|
[ObservableProperty] private int _turns;
|
||||||
|
[ObservableProperty] private int _tokens;
|
||||||
|
[ObservableProperty] private int _diffAdditions;
|
||||||
|
[ObservableProperty] private int _diffDeletions;
|
||||||
|
[ObservableProperty] private int _commitsOnBranch;
|
||||||
|
|
||||||
|
public string TokensFormatted => Tokens >= 1000 ? $"{Tokens / 1000.0:F1}k" : Tokens.ToString();
|
||||||
|
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
|
||||||
|
|
||||||
|
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
|
||||||
|
partial void OnDiffAdditionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||||||
|
partial void OnDiffDeletionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||||||
|
|
||||||
|
// 0.0–1.0 additions share for the diff meter
|
||||||
|
public double DiffMeterRatio
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var total = DiffAdditions + DiffDeletions;
|
||||||
|
return total == 0 ? 0.0 : (double)DiffAdditions / total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||||||
|
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||||
|
|
||||||
|
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||||
|
private readonly StreamLineFormatter _formatter = new();
|
||||||
|
private readonly StringBuilder _claudeBuf = new();
|
||||||
|
|
||||||
|
// The task ID we are currently subscribed to for live log messages
|
||||||
|
private string? _subscribedTaskId;
|
||||||
|
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
|
// Set by shell so CloseDetailCommand can clear SelectedTask
|
||||||
|
public Action? CloseDetail { get; set; }
|
||||||
|
|
||||||
|
// Set by shell so DeleteTaskCommand can remove from list
|
||||||
|
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
|
||||||
|
|
||||||
|
// Set by the view so OpenDiffCommand can show the modal as a dialog
|
||||||
|
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
|
||||||
|
|
||||||
|
// Set by the view so OpenWorktreeCommand can show the modal as a dialog
|
||||||
|
public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; }
|
||||||
|
|
||||||
|
// Set by the view so ApproveMergeCommand can show the modal as a dialog
|
||||||
|
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||||
|
|
||||||
|
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||||
|
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
|
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_worker = worker;
|
||||||
|
_services = services;
|
||||||
|
|
||||||
|
// Subscribe once; filter by current task id inside the handler
|
||||||
|
_worker.TaskMessageEvent += OnTaskMessage;
|
||||||
|
|
||||||
|
// Re-evaluate CanExecute when worker connection flips.
|
||||||
|
_worker.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||||
|
{
|
||||||
|
RunNowCommand.NotifyCanExecuteChanged();
|
||||||
|
ContinueCommand.NotifyCanExecuteChanged();
|
||||||
|
ResetCommand.NotifyCanExecuteChanged();
|
||||||
|
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
|
||||||
|
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
|
||||||
|
{
|
||||||
|
if (Task?.Id == taskId) AgentStatusLabel = "Running";
|
||||||
|
};
|
||||||
|
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
|
||||||
|
{
|
||||||
|
if (Task?.Id != taskId) return;
|
||||||
|
FlushClaudeBuffer();
|
||||||
|
Log.Add(new LogLineViewModel
|
||||||
|
{
|
||||||
|
Kind = LogKind.Done,
|
||||||
|
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
||||||
|
});
|
||||||
|
AgentStatusLabel = status;
|
||||||
|
// Re-query to pick up worktree created during the run.
|
||||||
|
_ = RefreshWorktreeAsync(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
_worker.WorktreeUpdatedEvent += taskId =>
|
||||||
|
{
|
||||||
|
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTaskMessage(string taskId, string line)
|
||||||
|
{
|
||||||
|
if (taskId != _subscribedTaskId) return;
|
||||||
|
|
||||||
|
// `[stdout] ...json...` lines are Claude CLI stream-json; parse through the
|
||||||
|
// formatter so the user sees human text, not raw JSON.
|
||||||
|
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var body = line["[stdout]".Length..].TrimStart();
|
||||||
|
var formatted = _formatter.FormatLine(body);
|
||||||
|
if (formatted is null) return; // filter noise (message_start, etc.)
|
||||||
|
AppendClaudeText(formatted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-stdout tagged lines: flush any buffered text then classify by prefix.
|
||||||
|
FlushClaudeBuffer();
|
||||||
|
|
||||||
|
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
|
||||||
|
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
|
||||||
|
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
|
||||||
|
: line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
|
||||||
|
: line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done
|
||||||
|
: LogKind.Msg;
|
||||||
|
Log.Add(new LogLineViewModel { Kind = kind, Text = line });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendClaudeText(string chunk)
|
||||||
|
{
|
||||||
|
_claudeBuf.Append(chunk);
|
||||||
|
// Emit a log entry for every completed line; keep the trailing remainder buffered.
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var text = _claudeBuf.ToString();
|
||||||
|
var nl = text.IndexOf('\n');
|
||||||
|
if (nl < 0) break;
|
||||||
|
var piece = text[..nl].TrimEnd('\r');
|
||||||
|
if (!string.IsNullOrWhiteSpace(piece))
|
||||||
|
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||||
|
_claudeBuf.Clear();
|
||||||
|
_claudeBuf.Append(text[(nl + 1)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushClaudeBuffer()
|
||||||
|
{
|
||||||
|
if (_claudeBuf.Length == 0) return;
|
||||||
|
var piece = _claudeBuf.ToString().TrimEnd();
|
||||||
|
_claudeBuf.Clear();
|
||||||
|
if (!string.IsNullOrWhiteSpace(piece))
|
||||||
|
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnTaskModelSelectionChanged(string value) => QueueAgentSave();
|
||||||
|
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
||||||
|
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave();
|
||||||
|
|
||||||
|
private void QueueAgentSave()
|
||||||
|
{
|
||||||
|
if (_suppressAgentSave || Task is null) return;
|
||||||
|
_agentSaveCts?.Cancel();
|
||||||
|
_agentSaveCts = new CancellationTokenSource();
|
||||||
|
var ct = _agentSaveCts.Token;
|
||||||
|
_ = SaveAgentSettingsAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await System.Threading.Tasks.Task.Delay(300, ct);
|
||||||
|
if (Task is null) return;
|
||||||
|
|
||||||
|
var model = TaskModelSelection == "(inherit)" ? null : TaskModelSelection;
|
||||||
|
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
||||||
|
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||||||
|
? null : TaskSelectedAgent.Path;
|
||||||
|
|
||||||
|
await _worker.UpdateTaskAgentSettingsAsync(
|
||||||
|
new ClaudeDo.Ui.Services.UpdateTaskAgentSettingsDto(Task.Id, model, sp, ap));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task LoadAgentSettingsAsync(
|
||||||
|
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_suppressAgentSave = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TaskAgentOptions.Clear();
|
||||||
|
TaskAgentOptions.Add(new AgentInfo("(inherit)", "", ""));
|
||||||
|
var agents = await _worker.GetAgentsAsync();
|
||||||
|
foreach (var a in agents) TaskAgentOptions.Add(a);
|
||||||
|
|
||||||
|
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? "(inherit)" : entity.Model!;
|
||||||
|
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
||||||
|
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||||||
|
? TaskAgentOptions[0]
|
||||||
|
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
|
||||||
|
|
||||||
|
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||||||
|
EffectiveModelHint = string.IsNullOrWhiteSpace(listCfg?.Model) ? "(global default)" : listCfg!.Model!;
|
||||||
|
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "(none)" : listCfg!.SystemPrompt!;
|
||||||
|
EffectiveAgentHint = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
||||||
|
? "(none)" : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressAgentSave = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Bind(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts?.Dispose();
|
||||||
|
_loadCts = new CancellationTokenSource();
|
||||||
|
var ct = _loadCts.Token;
|
||||||
|
|
||||||
|
Task = row;
|
||||||
|
OnPropertyChanged(nameof(TaskIdBadge));
|
||||||
|
Log.Clear();
|
||||||
|
Subtasks.Clear();
|
||||||
|
_claudeBuf.Clear();
|
||||||
|
|
||||||
|
if (row == null)
|
||||||
|
{
|
||||||
|
_subscribedTaskId = null;
|
||||||
|
EditableTitle = "";
|
||||||
|
Notes = "";
|
||||||
|
Model = null;
|
||||||
|
WorktreePath = null;
|
||||||
|
WorktreeStateLabel = null;
|
||||||
|
BranchLine = null;
|
||||||
|
AgentStatusLabel = "Idle";
|
||||||
|
LatestRunSessionId = null;
|
||||||
|
ShowFailedActions = false;
|
||||||
|
_suppressAgentSave = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TaskModelSelection = "(inherit)";
|
||||||
|
TaskSystemPrompt = "";
|
||||||
|
TaskSelectedAgent = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressAgentSave = false;
|
||||||
|
}
|
||||||
|
EffectiveModelHint = "";
|
||||||
|
EffectiveSystemPromptHint = "";
|
||||||
|
EffectiveAgentHint = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = BindAsync(row, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var subtaskRepo = new SubtaskRepository(ctx);
|
||||||
|
|
||||||
|
// Own query with Include so WorktreePath/BranchLine are populated.
|
||||||
|
var entity = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (entity == null) return;
|
||||||
|
|
||||||
|
EditableTitle = entity.Title;
|
||||||
|
Notes = entity.Notes ?? "";
|
||||||
|
Model = entity.Model;
|
||||||
|
WorktreePath = entity.Worktree?.Path;
|
||||||
|
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||||
|
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||||
|
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||||
|
AgentStatusLabel = entity.Status.ToString();
|
||||||
|
await LoadAgentSettingsAsync(entity, ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var runRepo = new TaskRunRepository(ctx);
|
||||||
|
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
LatestRunSessionId = latestRun?.SessionId;
|
||||||
|
|
||||||
|
// Subscribe only after DB load confirms the task exists
|
||||||
|
_subscribedTaskId = row.Id;
|
||||||
|
|
||||||
|
// Replay the latest run's persisted log so output is visible across app restarts.
|
||||||
|
await ReplayLogFileAsync(entity.LogPath, ct);
|
||||||
|
|
||||||
|
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
foreach (var s in subs)
|
||||||
|
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(logPath)) return;
|
||||||
|
var expanded = ExpandUserPath(logPath);
|
||||||
|
if (!System.IO.File.Exists(expanded)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const int maxLines = 2000;
|
||||||
|
string[] all;
|
||||||
|
await using (var fs = new System.IO.FileStream(
|
||||||
|
expanded,
|
||||||
|
System.IO.FileMode.Open,
|
||||||
|
System.IO.FileAccess.Read,
|
||||||
|
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
|
||||||
|
using (var reader = new System.IO.StreamReader(fs))
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
while (await reader.ReadLineAsync(ct) is { } line)
|
||||||
|
list.Add(line);
|
||||||
|
all = list.ToArray();
|
||||||
|
}
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var start = Math.Max(0, all.Length - maxLines);
|
||||||
|
for (int i = start; i < all.Length; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (_subscribedTaskId is null) return;
|
||||||
|
// Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts
|
||||||
|
// it with a "[stdout] " prefix. Match the live-stream format so the same
|
||||||
|
// stream-json parser handles both.
|
||||||
|
var line = all[i];
|
||||||
|
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
|
||||||
|
OnTaskMessage(_subscribedTaskId, normalized);
|
||||||
|
}
|
||||||
|
FlushClaudeBuffer();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch { /* best-effort replay */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExpandUserPath(string path)
|
||||||
|
{
|
||||||
|
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
|
||||||
|
return System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
path[2..]);
|
||||||
|
if (path == "~")
|
||||||
|
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
|
if (entity == null || Task?.Id != taskId) return;
|
||||||
|
|
||||||
|
WorktreePath = entity.Worktree?.Path;
|
||||||
|
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||||
|
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||||
|
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||||
|
AgentStatusLabel = entity.Status.ToString();
|
||||||
|
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
|
||||||
|
row.DiffStat = stat;
|
||||||
|
}
|
||||||
|
catch { /* best-effort refresh */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||||
|
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||||
|
{
|
||||||
|
if (WorktreePath == null || ShowDiffModal == null) return;
|
||||||
|
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
|
||||||
|
{
|
||||||
|
WorktreePath = WorktreePath,
|
||||||
|
BaseRef = WorktreeBaseCommit,
|
||||||
|
TaskId = Task?.Id,
|
||||||
|
TaskTitle = Task?.Title ?? "",
|
||||||
|
ShowMergeModal = ShowMergeModal,
|
||||||
|
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||||
|
};
|
||||||
|
await diffVm.LoadAsync();
|
||||||
|
await ShowDiffModal(diffVm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanOpenDiff() => WorktreePath != null;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||||
|
private void OpenWorktree()
|
||||||
|
{
|
||||||
|
if (WorktreePath == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = WorktreePath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* explorer open is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanOpenWorktree() => WorktreePath != null;
|
||||||
|
|
||||||
|
partial void OnWorktreePathChanged(string? value)
|
||||||
|
{
|
||||||
|
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||||
|
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||||
|
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnWorktreeStateLabelChanged(string? value)
|
||||||
|
{
|
||||||
|
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task SendPromptAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PromptInput) || Task == null) return;
|
||||||
|
Log.Add(new LogLineViewModel { Kind = LogKind.Msg, Text = $"[you] {PromptInput}" });
|
||||||
|
// TODO: WorkerClient has no SendPromptAsync — no matching hub method found.
|
||||||
|
// When the worker gains a "SendPrompt" hub method, call:
|
||||||
|
// await _worker.SendPromptAsync(Task.Id, PromptInput);
|
||||||
|
PromptInput = "";
|
||||||
|
await System.Threading.Tasks.Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseDetails() => CloseDetail?.Invoke();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task DeleteTaskAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
var row = Task;
|
||||||
|
if (ConfirmAsync != null)
|
||||||
|
{
|
||||||
|
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
await repo.DeleteAsync(row.Id);
|
||||||
|
if (DeleteFromList != null)
|
||||||
|
await DeleteFromList(row);
|
||||||
|
CloseDetail?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task AddSubtaskAsync()
|
||||||
|
{
|
||||||
|
if (Task is null) return;
|
||||||
|
var title = NewSubtaskTitle?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(title)) return;
|
||||||
|
|
||||||
|
var entity = new ClaudeDo.Data.Models.SubtaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
TaskId = Task.Id,
|
||||||
|
Title = title,
|
||||||
|
Completed = false,
|
||||||
|
OrderNum = Subtasks.Count,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
await new SubtaskRepository(ctx).AddAsync(entity);
|
||||||
|
|
||||||
|
Subtasks.Add(new SubtaskRowViewModel { Id = entity.Id, Title = entity.Title, Done = entity.Completed });
|
||||||
|
NewSubtaskTitle = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task SaveNotesAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
var entity = await repo.GetByIdAsync(Task.Id);
|
||||||
|
if (entity == null) return;
|
||||||
|
entity.Notes = Notes;
|
||||||
|
await repo.UpdateAsync(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanMerge))]
|
||||||
|
private async System.Threading.Tasks.Task ApproveMergeAsync()
|
||||||
|
{
|
||||||
|
if (Task == null || ShowMergeModal == null) return;
|
||||||
|
var vm = _services.GetRequiredService<MergeModalViewModel>();
|
||||||
|
await vm.InitializeAsync(Task.Id, Task.Title);
|
||||||
|
await ShowMergeModal(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanMerge() =>
|
||||||
|
Task != null && _worker.IsConnected && WorktreePath != null && WorktreeStateLabel == "Active";
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task StopAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
await _worker.CancelTaskAsync(Task.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
||||||
|
private async System.Threading.Tasks.Task RunNowAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
AgentStatusLabel = "Running";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.RunNowAsync(Task.Id);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
AgentStatusLabel = "Failed";
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanRunNow() =>
|
||||||
|
Task != null && _worker.IsConnected && !IsRunning;
|
||||||
|
|
||||||
|
[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 branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||||
|
var ok = await ConfirmAsync($"Discard worktree and reset task?\nThis deletes branch {branchName} and all uncommitted changes.");
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
await _worker.ResetTaskAsync(Task.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanReset() =>
|
||||||
|
Task != null && _worker.IsConnected && ShowFailedActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
[ObservableProperty] private string _title = "";
|
||||||
|
[ObservableProperty] private bool _done;
|
||||||
|
}
|
||||||
16
src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs
Normal file
16
src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required ListKind Kind { get; init; }
|
||||||
|
[ObservableProperty] private int _count;
|
||||||
|
[ObservableProperty] private bool _isActive;
|
||||||
|
[ObservableProperty] private string? _workingDir;
|
||||||
|
[ObservableProperty] private string _defaultCommitType = "chore";
|
||||||
|
public string? IconKey { get; init; }
|
||||||
|
public string? DotColorKey { get; init; }
|
||||||
|
}
|
||||||
196
src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
Normal file
196
src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
public enum ListKind { Smart, Virtual, User }
|
||||||
|
|
||||||
|
public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly IServiceProvider? _services;
|
||||||
|
private readonly WorkerClient? _worker;
|
||||||
|
|
||||||
|
public event EventHandler? SelectionChanged;
|
||||||
|
public event EventHandler? FocusSearchRequested;
|
||||||
|
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||||
|
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenSettings()
|
||||||
|
{
|
||||||
|
if (ShowSettingsModal is null || _services is null) return;
|
||||||
|
var settingsVm = _services.GetRequiredService<SettingsModalViewModel>();
|
||||||
|
await settingsVm.LoadAsync();
|
||||||
|
await ShowSettingsModal(settingsVm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || ShowListSettingsModal is null || _services is null) return;
|
||||||
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
|
await vm.LoadAsync(row.Id, row.Name, row.WorkingDir, row.DefaultCommitType);
|
||||||
|
await ShowListSettingsModal(vm);
|
||||||
|
await RefreshRowAsync(row.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
||||||
|
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
|
||||||
|
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _searchText = "";
|
||||||
|
[ObservableProperty] private ListNavItemViewModel? _selectedList;
|
||||||
|
|
||||||
|
public string UserName { get; } = Environment.UserName;
|
||||||
|
public string MachineName { get; } = Environment.MachineName;
|
||||||
|
public string UserInitials { get; }
|
||||||
|
|
||||||
|
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_services = services;
|
||||||
|
_worker = worker;
|
||||||
|
var parts = Environment.UserName.Split('.', '_', '-', ' ');
|
||||||
|
UserInitials = parts.Length >= 2
|
||||||
|
? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant()
|
||||||
|
: Environment.UserName.Length >= 2
|
||||||
|
? Environment.UserName[..2].ToUpperInvariant()
|
||||||
|
: Environment.UserName.ToUpperInvariant();
|
||||||
|
|
||||||
|
if (_worker is not null)
|
||||||
|
{
|
||||||
|
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
|
||||||
|
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
||||||
|
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||||
|
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Items.Clear();
|
||||||
|
SmartLists.Clear();
|
||||||
|
UserLists.Clear();
|
||||||
|
|
||||||
|
var smart = new[]
|
||||||
|
{
|
||||||
|
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
|
||||||
|
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
|
||||||
|
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
|
||||||
|
new ListNavItemViewModel { Id = "virtual:queued", Name = "Queue", Kind = ListKind.Virtual, IconKey = "Inbox" },
|
||||||
|
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
|
||||||
|
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
|
||||||
|
};
|
||||||
|
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var lists = new ListRepository(ctx);
|
||||||
|
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||||
|
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||||
|
int idx = 0;
|
||||||
|
foreach (var l in await lists.GetAllAsync(ct))
|
||||||
|
{
|
||||||
|
if (seedNames.Contains(l.Name)) continue;
|
||||||
|
var item = new ListNavItemViewModel
|
||||||
|
{
|
||||||
|
Id = $"user:{l.Id}",
|
||||||
|
Name = l.Name,
|
||||||
|
Kind = ListKind.User,
|
||||||
|
IconKey = "Folder",
|
||||||
|
DotColorKey = dotColors[idx % dotColors.Length],
|
||||||
|
WorkingDir = l.WorkingDir,
|
||||||
|
DefaultCommitType = l.DefaultCommitType,
|
||||||
|
};
|
||||||
|
Items.Add(item);
|
||||||
|
UserLists.Add(item);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RefreshCountsAsync(ct);
|
||||||
|
SelectedList = Items.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
|
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
|
||||||
|
var open = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Status != TaskStatus.Done)
|
||||||
|
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var running = open.Count(t => t.Status == TaskStatus.Running);
|
||||||
|
var queued = open.Count(t => t.Status == TaskStatus.Queued);
|
||||||
|
var review = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active)
|
||||||
|
.CountAsync(ct);
|
||||||
|
|
||||||
|
foreach (var item in SmartLists)
|
||||||
|
{
|
||||||
|
item.Count = item.Id switch
|
||||||
|
{
|
||||||
|
"smart:my-day" => open.Count(t => t.IsMyDay),
|
||||||
|
"smart:important" => open.Count(t => t.IsStarred),
|
||||||
|
"smart:planned" => open.Count(t => t.Scheduled != null),
|
||||||
|
"virtual:queued" => queued,
|
||||||
|
"virtual:running" => running,
|
||||||
|
"virtual:review" => review,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in UserLists)
|
||||||
|
{
|
||||||
|
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
|
||||||
|
? item.Id["user:".Length..]
|
||||||
|
: item.Id;
|
||||||
|
item.Count = open.Count(t => t.ListId == listId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch { /* best-effort refresh */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Select(ListNavItemViewModel item) => SelectedList = item;
|
||||||
|
|
||||||
|
partial void OnSelectedListChanged(ListNavItemViewModel? value)
|
||||||
|
{
|
||||||
|
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
|
||||||
|
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task RefreshRowAsync(string rowId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
|
||||||
|
var row = UserLists.FirstOrDefault(r => r.Id == rowId);
|
||||||
|
if (row is null) return;
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var lists = new ListRepository(ctx);
|
||||||
|
var entity = await lists.GetByIdAsync(rawId);
|
||||||
|
if (entity is null) return;
|
||||||
|
|
||||||
|
row.WorkingDir = entity.WorkingDir;
|
||||||
|
row.DefaultCommitType = entity.DefaultCommitType;
|
||||||
|
}
|
||||||
|
catch { /* best-effort refresh */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs
Normal file
32
src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
|
||||||
|
|
||||||
|
public sealed class LogLineViewModel
|
||||||
|
{
|
||||||
|
public required LogKind Kind { get; init; }
|
||||||
|
public required string Text { get; init; }
|
||||||
|
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
|
||||||
|
public string KindMarker => Kind switch
|
||||||
|
{
|
||||||
|
LogKind.Sys => "sys",
|
||||||
|
LogKind.Tool => "tool",
|
||||||
|
LogKind.Claude => "claude",
|
||||||
|
LogKind.Stdout => "out",
|
||||||
|
LogKind.Stderr => "err",
|
||||||
|
LogKind.Done => "done",
|
||||||
|
LogKind.Msg => "claude",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
public string ClassName => Kind switch
|
||||||
|
{
|
||||||
|
LogKind.Sys => "log-sys",
|
||||||
|
LogKind.Tool => "log-tool",
|
||||||
|
LogKind.Claude => "log-claude",
|
||||||
|
LogKind.Stdout => "log-stdout",
|
||||||
|
LogKind.Stderr => "log-stderr",
|
||||||
|
LogKind.Done => "log-done",
|
||||||
|
LogKind.Msg => "log-msg",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
110
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
Normal file
110
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
public sealed partial class TaskRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
[ObservableProperty] private string _title = "";
|
||||||
|
[ObservableProperty] private string _listName = "";
|
||||||
|
[ObservableProperty] private bool _done;
|
||||||
|
[ObservableProperty] private bool _isStarred;
|
||||||
|
[ObservableProperty] private bool _isMyDay;
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
[ObservableProperty] private TaskStatus _status;
|
||||||
|
[ObservableProperty] private string? _branch;
|
||||||
|
[ObservableProperty] private string? _diffStat;
|
||||||
|
[ObservableProperty] private string? _liveTail;
|
||||||
|
[ObservableProperty] private DateTime? _scheduledFor;
|
||||||
|
[ObservableProperty] private int _diffAdditions;
|
||||||
|
[ObservableProperty] private int _diffDeletions;
|
||||||
|
[ObservableProperty] private bool _dropHintAbove;
|
||||||
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||||
|
public int StepsCount { get; init; }
|
||||||
|
public int StepsCompleted { get; init; }
|
||||||
|
|
||||||
|
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||||||
|
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||||||
|
public bool HasTags => Tags.Count > 0;
|
||||||
|
public bool HasSteps => StepsCount > 0;
|
||||||
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
|
public bool IsRunning => Status == TaskStatus.Running;
|
||||||
|
public bool IsQueued => Status == TaskStatus.Queued;
|
||||||
|
public bool HasSchedule => ScheduledFor.HasValue;
|
||||||
|
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||||
|
|
||||||
|
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||||||
|
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||||
|
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
|
||||||
|
|
||||||
|
public string StatusChipClass => Status switch
|
||||||
|
{
|
||||||
|
TaskStatus.Running => "running",
|
||||||
|
TaskStatus.Failed => "error",
|
||||||
|
TaskStatus.Done => "review",
|
||||||
|
TaskStatus.Queued => "queued",
|
||||||
|
_ => "idle",
|
||||||
|
};
|
||||||
|
|
||||||
|
partial void OnStatusChanged(TaskStatus value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(StatusChipClass));
|
||||||
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
|
OnPropertyChanged(nameof(HasLiveTail));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||||
|
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||||||
|
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||||
|
partial void OnScheduledForChanged(DateTime? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsOverdue));
|
||||||
|
OnPropertyChanged(nameof(HasSchedule));
|
||||||
|
}
|
||||||
|
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
||||||
|
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
||||||
|
|
||||||
|
public static TaskRowViewModel FromEntity(TaskEntity t)
|
||||||
|
{
|
||||||
|
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
||||||
|
return new TaskRowViewModel
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
Title = t.Title,
|
||||||
|
ListName = t.List?.Name ?? "",
|
||||||
|
Done = t.Status == TaskStatus.Done,
|
||||||
|
IsStarred = t.IsStarred,
|
||||||
|
IsMyDay = t.IsMyDay,
|
||||||
|
Status = t.Status,
|
||||||
|
Branch = t.Worktree?.BranchName,
|
||||||
|
DiffStat = t.Worktree?.DiffStat,
|
||||||
|
ScheduledFor = t.ScheduledFor,
|
||||||
|
DiffAdditions = add,
|
||||||
|
DiffDeletions = del,
|
||||||
|
CreatedAt = t.CreatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||||
|
private static (int add, int del) ParseDiffStat(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return (0, 0);
|
||||||
|
int add = 0, del = 0;
|
||||||
|
var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var p in parts)
|
||||||
|
{
|
||||||
|
if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a;
|
||||||
|
else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d;
|
||||||
|
}
|
||||||
|
return (add, del);
|
||||||
|
}
|
||||||
|
}
|
||||||
362
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
Normal file
362
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly WorkerClient? _worker;
|
||||||
|
private ListNavItemViewModel? _currentList;
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
|
public event EventHandler? SelectionChanged;
|
||||||
|
public event EventHandler? FocusAddTaskRequested;
|
||||||
|
public event EventHandler? TasksChanged;
|
||||||
|
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
|
||||||
|
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
||||||
|
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
||||||
|
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _newTaskTitle = "";
|
||||||
|
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
||||||
|
[ObservableProperty] private string _headerTitle = "";
|
||||||
|
[ObservableProperty] private string _headerEyebrow = "";
|
||||||
|
[ObservableProperty] private string _subtitle = "";
|
||||||
|
[ObservableProperty] private string _statusPill = "";
|
||||||
|
[ObservableProperty] private bool _hasStatusPill;
|
||||||
|
[ObservableProperty] private bool _isShowingCompleted = true;
|
||||||
|
[ObservableProperty] private bool _hasOverdue;
|
||||||
|
[ObservableProperty] private bool _hasOpen;
|
||||||
|
[ObservableProperty] private bool _hasCompleted;
|
||||||
|
[ObservableProperty] private bool _showOpenLabel;
|
||||||
|
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
||||||
|
|
||||||
|
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_worker = worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadForList(ListNavItemViewModel? list)
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts?.Dispose();
|
||||||
|
_loadCts = new CancellationTokenSource();
|
||||||
|
var ct = _loadCts.Token;
|
||||||
|
|
||||||
|
_currentList = list;
|
||||||
|
Items.Clear();
|
||||||
|
OverdueItems.Clear();
|
||||||
|
OpenItems.Clear();
|
||||||
|
CompletedItems.Clear();
|
||||||
|
HasOverdue = false;
|
||||||
|
HasOpen = false;
|
||||||
|
HasCompleted = false;
|
||||||
|
ShowOpenLabel = false;
|
||||||
|
if (list is null) return;
|
||||||
|
|
||||||
|
HeaderTitle = list.Name;
|
||||||
|
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
||||||
|
|
||||||
|
_ = LoadForListAsync(list, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var all = await db.Tasks
|
||||||
|
.Include(t => t.List)
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
IEnumerable<TaskEntity> filtered = list.Kind switch
|
||||||
|
{
|
||||||
|
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
||||||
|
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
||||||
|
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => t.Status == TaskStatus.Queued),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
||||||
|
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
||||||
|
_ => Enumerable.Empty<TaskEntity>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var t in filtered)
|
||||||
|
Items.Add(TaskRowViewModel.FromEntity(t));
|
||||||
|
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Regroup()
|
||||||
|
{
|
||||||
|
OverdueItems.Clear();
|
||||||
|
OpenItems.Clear();
|
||||||
|
CompletedItems.Clear();
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
foreach (var r in Items)
|
||||||
|
{
|
||||||
|
if (r.Done)
|
||||||
|
CompletedItems.Add(r);
|
||||||
|
else if (r.ScheduledFor is { } d && d.Date < today)
|
||||||
|
OverdueItems.Add(r);
|
||||||
|
else
|
||||||
|
OpenItems.Add(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
HasOverdue = OverdueItems.Count > 0;
|
||||||
|
HasOpen = OpenItems.Count > 0;
|
||||||
|
HasCompleted = CompletedItems.Count > 0;
|
||||||
|
ShowOpenLabel = HasOpen && HasOverdue;
|
||||||
|
CompletedHeader = $"COMPLETED · {CompletedItems.Count}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSubtitle()
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var open = Items.Count(i => !i.Done);
|
||||||
|
var running = Items.Count(i => i.Status == TaskStatus.Running);
|
||||||
|
var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null);
|
||||||
|
|
||||||
|
Subtitle = open == 1 ? "1 open task" : $"{open} open tasks";
|
||||||
|
|
||||||
|
if (running > 0 || review > 0)
|
||||||
|
{
|
||||||
|
StatusPill = $"{running} running · {review} review";
|
||||||
|
HasStatusPill = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusPill = "";
|
||||||
|
HasStatusPill = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
||||||
|
var listId = _currentList.Id["user:".Length..];
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var maxSort = await db.Tasks
|
||||||
|
.Where(t => t.ListId == listId)
|
||||||
|
.Select(t => (int?)t.SortOrder)
|
||||||
|
.MaxAsync();
|
||||||
|
var entity = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
|
ListId = listId,
|
||||||
|
Title = NewTaskTitle.Trim(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
SortOrder = (maxSort ?? -1) + 1,
|
||||||
|
};
|
||||||
|
db.Tasks.Add(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var row = TaskRowViewModel.FromEntity(entity);
|
||||||
|
Items.Add(row);
|
||||||
|
Regroup();
|
||||||
|
NewTaskTitle = "";
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
||||||
|
|
||||||
|
public void ClearDropHints()
|
||||||
|
{
|
||||||
|
foreach (var r in Items)
|
||||||
|
{
|
||||||
|
r.DropHintAbove = false;
|
||||||
|
r.DropHintBelow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetDropHint(TaskRowViewModel target, bool placeBelow)
|
||||||
|
{
|
||||||
|
foreach (var r in Items)
|
||||||
|
{
|
||||||
|
var isTarget = ReferenceEquals(r, target);
|
||||||
|
r.DropHintAbove = isTarget && !placeBelow;
|
||||||
|
r.DropHintBelow = isTarget && placeBelow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReorderAsync(TaskRowViewModel source, TaskRowViewModel target, bool placeBelow)
|
||||||
|
{
|
||||||
|
if (!CanReorder || _currentList is null) return;
|
||||||
|
if (source.IsRunning || target.IsRunning) return;
|
||||||
|
if (ReferenceEquals(source, target)) return;
|
||||||
|
|
||||||
|
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
|
||||||
|
MoveWithinCollection(Items, source, target, placeBelow);
|
||||||
|
|
||||||
|
// Apply the same move in whichever section the row lives in.
|
||||||
|
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
|
||||||
|
// that's determined by Done flag and ScheduledFor date, not drag-drop.
|
||||||
|
var sourceSection = SectionFor(source);
|
||||||
|
var targetSection = SectionFor(target);
|
||||||
|
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
|
||||||
|
MoveWithinCollection(sourceSection, source, target, placeBelow);
|
||||||
|
|
||||||
|
var listId = _currentList.Id["user:".Length..];
|
||||||
|
var orderedIds = Items.Select(i => i.Id).ToList();
|
||||||
|
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var idSet = orderedIds.ToHashSet();
|
||||||
|
var entities = await db.Tasks
|
||||||
|
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
for (int i = 0; i < orderedIds.Count; i++)
|
||||||
|
{
|
||||||
|
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
|
||||||
|
if (e is not null) e.SortOrder = i;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveWithinCollection(
|
||||||
|
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
|
||||||
|
TaskRowViewModel source,
|
||||||
|
TaskRowViewModel target,
|
||||||
|
bool placeBelow)
|
||||||
|
{
|
||||||
|
var srcIdx = coll.IndexOf(source);
|
||||||
|
var tgtIdx = coll.IndexOf(target);
|
||||||
|
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
|
||||||
|
|
||||||
|
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
|
||||||
|
if (srcIdx < finalIdx) finalIdx--;
|
||||||
|
if (finalIdx < 0) finalIdx = 0;
|
||||||
|
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
|
||||||
|
if (finalIdx == srcIdx) return;
|
||||||
|
|
||||||
|
coll.Move(srcIdx, finalIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
if (OverdueItems.Contains(row)) return OverdueItems;
|
||||||
|
if (OpenItems.Contains(row)) return OpenItems;
|
||||||
|
if (CompletedItems.Contains(row)) return CompletedItems;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
row.Done = !row.Done;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity != null)
|
||||||
|
{
|
||||||
|
entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Manual;
|
||||||
|
row.Status = entity.Status;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ToggleStarAsync(TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
row.IsStarred = !row.IsStarred;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity != null)
|
||||||
|
{
|
||||||
|
entity.IsStarred = row.IsStarred;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || row.IsRunning) return;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity is null) return;
|
||||||
|
entity.Status = TaskStatus.Queued;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
row.Status = TaskStatus.Queued;
|
||||||
|
if (_worker is not null)
|
||||||
|
{
|
||||||
|
try { await _worker.WakeQueueAsync(); } catch { }
|
||||||
|
}
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity is null) return;
|
||||||
|
entity.Status = TaskStatus.Manual;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
row.Status = TaskStatus.Manual;
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity is null) return;
|
||||||
|
entity.ScheduledFor = when;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
row.ScheduledFor = when;
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task ClearScheduleAsync(TaskRowViewModel? row) =>
|
||||||
|
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Select(TaskRowViewModel row) => SelectedTask = row;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Sort() { /* placeholder — UI-only */ }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void More() { /* placeholder — UI-only */ }
|
||||||
|
|
||||||
|
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
||||||
|
{
|
||||||
|
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
||||||
|
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
Normal file
73
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public ListsIslandViewModel Lists { get; }
|
||||||
|
public TasksIslandViewModel Tasks { get; }
|
||||||
|
public DetailsIslandViewModel Details { get; }
|
||||||
|
public WorkerClient Worker { get; }
|
||||||
|
|
||||||
|
public string ConnectionText =>
|
||||||
|
Worker.IsConnected ? "Online"
|
||||||
|
: Worker.IsReconnecting ? "Connecting…"
|
||||||
|
: "Offline";
|
||||||
|
|
||||||
|
public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _windowWidth = 1280;
|
||||||
|
|
||||||
|
public bool ShowDetails => WindowWidth >= 1100;
|
||||||
|
public bool ShowLists => WindowWidth >= 780;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void FocusSearch() => Lists.RequestFocusSearch();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void FocusAddTask() => Tasks.RequestFocusAddTask();
|
||||||
|
|
||||||
|
public async Task ToggleSelectedDoneAsync()
|
||||||
|
{
|
||||||
|
if (Tasks.SelectedTask is { } row)
|
||||||
|
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnWindowWidthChanged(double value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowDetails));
|
||||||
|
OnPropertyChanged(nameof(ShowLists));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IslandsShellViewModel(
|
||||||
|
ListsIslandViewModel lists,
|
||||||
|
TasksIslandViewModel tasks,
|
||||||
|
DetailsIslandViewModel details,
|
||||||
|
WorkerClient worker)
|
||||||
|
{
|
||||||
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||||
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||||
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||||
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||||
|
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
||||||
|
Details.DeleteFromList = row =>
|
||||||
|
{
|
||||||
|
Tasks.LoadForList(Lists.SelectedList);
|
||||||
|
_ = Lists.RefreshCountsAsync();
|
||||||
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
|
};
|
||||||
|
Worker.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ConnectionText));
|
||||||
|
OnPropertyChanged(nameof(IsOffline));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_ = Lists.LoadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using AgentInfo = ClaudeDo.Data.Models.AgentInfo;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class ListEditorViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
[ObservableProperty] private string _name = "";
|
|
||||||
[ObservableProperty] private string? _workingDir;
|
|
||||||
[ObservableProperty] private string _defaultCommitType = "chore";
|
|
||||||
[ObservableProperty] private string _windowTitle = "New List";
|
|
||||||
|
|
||||||
// Config fields
|
|
||||||
[ObservableProperty] private string _model = "Sonnet";
|
|
||||||
[ObservableProperty] private string? _systemPrompt;
|
|
||||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
|
||||||
|
|
||||||
private string? _editId;
|
|
||||||
private DateTime _createdAt;
|
|
||||||
private TaskCompletionSource<ListEntity?> _tcs = new();
|
|
||||||
|
|
||||||
public event Action? RequestClose;
|
|
||||||
|
|
||||||
public static string[] CommitTypes { get; } =
|
|
||||||
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
|
|
||||||
|
|
||||||
public static string[] ModelDisplayNames { get; } = ["Sonnet", "Opus", "Haiku"];
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, string> ModelToId = new()
|
|
||||||
{
|
|
||||||
["Sonnet"] = "claude-sonnet-4-6",
|
|
||||||
["Opus"] = "claude-opus-4-6",
|
|
||||||
["Haiku"] = "claude-haiku-4-5",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, string> IdToModel =
|
|
||||||
ModelToId.ToDictionary(kv => kv.Value, kv => kv.Key);
|
|
||||||
|
|
||||||
public static string ModelIdToDisplay(string? modelId) =>
|
|
||||||
modelId is not null && IdToModel.TryGetValue(modelId, out var display) ? display : "Sonnet";
|
|
||||||
|
|
||||||
public static string? ModelDisplayToId(string display) =>
|
|
||||||
ModelToId.TryGetValue(display, out var id) ? id : null;
|
|
||||||
|
|
||||||
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
|
||||||
|
|
||||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
|
||||||
{
|
|
||||||
AvailableAgents = await worker.GetAgentsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InitForCreate()
|
|
||||||
{
|
|
||||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
|
||||||
_editId = null;
|
|
||||||
_createdAt = DateTime.UtcNow;
|
|
||||||
WindowTitle = "New List";
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
|
|
||||||
{
|
|
||||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
|
||||||
_editId = entity.Id;
|
|
||||||
_createdAt = entity.CreatedAt;
|
|
||||||
Name = entity.Name;
|
|
||||||
WorkingDir = entity.WorkingDir;
|
|
||||||
DefaultCommitType = entity.DefaultCommitType;
|
|
||||||
WindowTitle = $"Edit List: {entity.Name}";
|
|
||||||
|
|
||||||
if (config is not null)
|
|
||||||
{
|
|
||||||
Model = ModelIdToDisplay(config.Model);
|
|
||||||
SystemPrompt = config.SystemPrompt;
|
|
||||||
SelectedAgent = AvailableAgents.FirstOrDefault(a => a.Path == config.AgentPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ListConfigEntity? BuildConfig(string listId)
|
|
||||||
{
|
|
||||||
var modelId = ModelDisplayToId(Model);
|
|
||||||
if (modelId is null && SystemPrompt is null && SelectedAgent is null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new ListConfigEntity
|
|
||||||
{
|
|
||||||
ListId = listId,
|
|
||||||
Model = modelId,
|
|
||||||
SystemPrompt = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt.Trim(),
|
|
||||||
AgentPath = SelectedAgent?.Path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void Save()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(Name)) return;
|
|
||||||
var entity = new ListEntity
|
|
||||||
{
|
|
||||||
Id = _editId ?? Guid.NewGuid().ToString(),
|
|
||||||
Name = Name.Trim(),
|
|
||||||
CreatedAt = _createdAt,
|
|
||||||
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir.Trim(),
|
|
||||||
DefaultCommitType = DefaultCommitType,
|
|
||||||
};
|
|
||||||
_tcs.TrySetResult(entity);
|
|
||||||
RequestClose?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void Cancel()
|
|
||||||
{
|
|
||||||
_tcs.TrySetResult(null);
|
|
||||||
RequestClose?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnWindowClosed()
|
|
||||||
{
|
|
||||||
_tcs.TrySetResult(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<ListEntity?> ShowAndWaitAsync() => _tcs.Task;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class ListItemViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
[ObservableProperty] private string _name;
|
|
||||||
[ObservableProperty] private string? _workingDir;
|
|
||||||
[ObservableProperty] private string _defaultCommitType;
|
|
||||||
|
|
||||||
private static readonly IBrush[] DotPalette =
|
|
||||||
[
|
|
||||||
new SolidColorBrush(Color.Parse("#3d9474")), // green
|
|
||||||
new SolidColorBrush(Color.Parse("#5571a1")), // blue
|
|
||||||
new SolidColorBrush(Color.Parse("#d4964a")), // amber
|
|
||||||
new SolidColorBrush(Color.Parse("#7c6aad")), // purple
|
|
||||||
new SolidColorBrush(Color.Parse("#c25d6a")), // rose
|
|
||||||
];
|
|
||||||
|
|
||||||
public IBrush DotBrush => DotPalette[Math.Abs(Id.GetHashCode()) % DotPalette.Length];
|
|
||||||
|
|
||||||
public string Id { get; }
|
|
||||||
|
|
||||||
public ListItemViewModel(ListEntity entity)
|
|
||||||
{
|
|
||||||
Id = entity.Id;
|
|
||||||
_name = entity.Name;
|
|
||||||
_workingDir = entity.WorkingDir;
|
|
||||||
_defaultCommitType = entity.DefaultCommitType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ListEntity ToEntity() => new()
|
|
||||||
{
|
|
||||||
Id = Id,
|
|
||||||
Name = Name,
|
|
||||||
CreatedAt = DateTime.MinValue, // not used for update
|
|
||||||
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
|
||||||
DefaultCommitType = DefaultCommitType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using ClaudeDo.Ui.Views;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel : ViewModelBase, IDisposable
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
||||||
private readonly WorkerClient _worker;
|
|
||||||
private readonly Func<ListEditorViewModel> _listEditorFactory;
|
|
||||||
|
|
||||||
public ObservableCollection<ListItemViewModel> Lists { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty] private ListItemViewModel? _selectedList;
|
|
||||||
|
|
||||||
public TaskListViewModel TaskList { get; }
|
|
||||||
public TaskDetailViewModel TaskDetail { get; }
|
|
||||||
public StatusBarViewModel StatusBar { get; }
|
|
||||||
|
|
||||||
private readonly Action<string> _onTaskChanged;
|
|
||||||
|
|
||||||
public MainWindowViewModel(
|
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
||||||
WorkerClient worker,
|
|
||||||
TaskListViewModel taskList,
|
|
||||||
TaskDetailViewModel taskDetail,
|
|
||||||
StatusBarViewModel statusBar,
|
|
||||||
Func<ListEditorViewModel> listEditorFactory)
|
|
||||||
{
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_worker = worker;
|
|
||||||
_listEditorFactory = listEditorFactory;
|
|
||||||
TaskList = taskList;
|
|
||||||
TaskDetail = taskDetail;
|
|
||||||
StatusBar = statusBar;
|
|
||||||
|
|
||||||
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
|
|
||||||
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
|
|
||||||
TaskDetail.TaskChanged += _onTaskChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
TaskList.SelectedTaskChanged -= OnSelectedTaskChanged;
|
|
||||||
TaskDetail.TaskChanged -= _onTaskChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
var lists = await listRepo.GetAllAsync();
|
|
||||||
foreach (var l in lists)
|
|
||||||
Lists.Add(new ListItemViewModel(l));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusBar.ShowMessage($"Error loading lists: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = _worker.StartAsync().ContinueWith(t =>
|
|
||||||
{
|
|
||||||
if (t.IsFaulted)
|
|
||||||
System.Diagnostics.Debug.WriteLine($"Worker connection failed: {t.Exception?.Message}");
|
|
||||||
}, TaskScheduler.Default);
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnSelectedListChanged(ListItemViewModel? value)
|
|
||||||
{
|
|
||||||
_ = TaskList.LoadAsync(value?.Id);
|
|
||||||
TaskDetail.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnSelectedTaskChanged(TaskItemViewModel? task)
|
|
||||||
{
|
|
||||||
if (task is null)
|
|
||||||
TaskDetail.Clear();
|
|
||||||
else
|
|
||||||
await TaskDetail.LoadAsync(task.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task AddList()
|
|
||||||
{
|
|
||||||
var editor = _listEditorFactory();
|
|
||||||
await editor.LoadAgentsAsync(_worker);
|
|
||||||
editor.InitForCreate();
|
|
||||||
|
|
||||||
var window = new ListEditorView { DataContext = editor };
|
|
||||||
editor.RequestClose += () => window.Close();
|
|
||||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
|
||||||
_ = ShowDialogAsync(window);
|
|
||||||
|
|
||||||
var entity = await editor.ShowAndWaitAsync();
|
|
||||||
if (entity is null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
await listRepo.AddAsync(entity);
|
|
||||||
var configEntity = editor.BuildConfig(entity.Id);
|
|
||||||
if (configEntity is not null)
|
|
||||||
await listRepo.SetConfigAsync(configEntity);
|
|
||||||
Lists.Add(new ListItemViewModel(entity));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusBar.ShowMessage($"Error creating list: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task EditList()
|
|
||||||
{
|
|
||||||
if (SelectedList is null) return;
|
|
||||||
|
|
||||||
ListEntity? existing;
|
|
||||||
ListConfigEntity? config;
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
existing = await listRepo.GetByIdAsync(SelectedList.Id);
|
|
||||||
if (existing is null) return;
|
|
||||||
config = await listRepo.GetConfigAsync(existing.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var editor = _listEditorFactory();
|
|
||||||
await editor.LoadAgentsAsync(_worker);
|
|
||||||
editor.InitForEdit(existing, config);
|
|
||||||
|
|
||||||
var window = new ListEditorView { DataContext = editor };
|
|
||||||
editor.RequestClose += () => window.Close();
|
|
||||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
|
||||||
_ = ShowDialogAsync(window);
|
|
||||||
|
|
||||||
var entity = await editor.ShowAndWaitAsync();
|
|
||||||
if (entity is null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
await listRepo.UpdateAsync(entity);
|
|
||||||
var configEntity = editor.BuildConfig(entity.Id);
|
|
||||||
if (configEntity is not null)
|
|
||||||
await listRepo.SetConfigAsync(configEntity);
|
|
||||||
SelectedList.Name = entity.Name;
|
|
||||||
SelectedList.WorkingDir = entity.WorkingDir;
|
|
||||||
SelectedList.DefaultCommitType = entity.DefaultCommitType;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusBar.ShowMessage($"Error updating list: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[ObservableProperty] private bool _isDeleteConfirmVisible;
|
|
||||||
private ListItemViewModel? _pendingDeleteList;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void DeleteList()
|
|
||||||
{
|
|
||||||
if (SelectedList is null) return;
|
|
||||||
_pendingDeleteList = SelectedList;
|
|
||||||
IsDeleteConfirmVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ConfirmDeleteList()
|
|
||||||
{
|
|
||||||
IsDeleteConfirmVisible = false;
|
|
||||||
if (_pendingDeleteList is null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
await listRepo.DeleteAsync(_pendingDeleteList.Id);
|
|
||||||
Lists.Remove(_pendingDeleteList);
|
|
||||||
if (SelectedList == _pendingDeleteList)
|
|
||||||
SelectedList = null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusBar.ShowMessage($"Error deleting list: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_pendingDeleteList = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void CancelDeleteList()
|
|
||||||
{
|
|
||||||
IsDeleteConfirmVisible = false;
|
|
||||||
_pendingDeleteList = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ShowDialogAsync(Window dialog)
|
|
||||||
{
|
|
||||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
|
|
||||||
&& desktop.MainWindow is not null)
|
|
||||||
{
|
|
||||||
await dialog.ShowDialog(desktop.MainWindow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
dialog.Show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
190
src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs
Normal file
190
src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public enum DiffLineKind { Add, Del, Ctx }
|
||||||
|
|
||||||
|
public sealed class DiffLineViewModel
|
||||||
|
{
|
||||||
|
public required DiffLineKind Kind { get; init; }
|
||||||
|
public int? OldNo { get; init; }
|
||||||
|
public int? NewNo { get; init; }
|
||||||
|
public required string Text { get; init; }
|
||||||
|
public string ClassName => Kind switch
|
||||||
|
{
|
||||||
|
DiffLineKind.Add => "add",
|
||||||
|
DiffLineKind.Del => "del",
|
||||||
|
_ => "ctx",
|
||||||
|
};
|
||||||
|
|
||||||
|
public string Sign => Kind switch
|
||||||
|
{
|
||||||
|
DiffLineKind.Add => "+",
|
||||||
|
DiffLineKind.Del => "-",
|
||||||
|
_ => " ",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DiffFileViewModel
|
||||||
|
{
|
||||||
|
public required string Path { get; init; }
|
||||||
|
public int Additions { get; set; }
|
||||||
|
public int Deletions { get; set; }
|
||||||
|
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class DiffModalViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly GitService _git;
|
||||||
|
|
||||||
|
public required string WorktreePath { get; init; }
|
||||||
|
public string? BaseRef { get; init; }
|
||||||
|
public string? TaskId { get; init; }
|
||||||
|
public string TaskTitle { get; init; } = "";
|
||||||
|
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||||
|
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||||
|
|
||||||
|
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private DiffFileViewModel? _selectedFile;
|
||||||
|
[ObservableProperty] private string? _statusMessage;
|
||||||
|
|
||||||
|
// Injected action to close the owning Window
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
public DiffModalViewModel(GitService git)
|
||||||
|
{
|
||||||
|
_git = git;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Close() => CloseAction?.Invoke();
|
||||||
|
|
||||||
|
private bool CanMerge() =>
|
||||||
|
!string.IsNullOrEmpty(TaskId)
|
||||||
|
&& ShowMergeModal is not null
|
||||||
|
&& ResolveMergeVm is not null;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanMerge))]
|
||||||
|
private async Task MergeAsync()
|
||||||
|
{
|
||||||
|
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
|
||||||
|
var vm = ResolveMergeVm();
|
||||||
|
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||||
|
await ShowMergeModal(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Files.Clear();
|
||||||
|
StatusMessage = null;
|
||||||
|
|
||||||
|
string raw;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
raw = BaseRef is not null
|
||||||
|
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||||
|
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Failed to load diff: {ex.Message}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
StatusMessage = "No changes to show.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse unified diff — state machine over lines
|
||||||
|
DiffFileViewModel? current = null;
|
||||||
|
int oldLine = 0, newLine = 0;
|
||||||
|
|
||||||
|
foreach (var line in raw.Split('\n'))
|
||||||
|
{
|
||||||
|
if (line.StartsWith("diff --git ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs"
|
||||||
|
var parts = line.Split(' ');
|
||||||
|
var path = parts.Length >= 4 ? parts[3][2..] : line;
|
||||||
|
current = new DiffFileViewModel { Path = path };
|
||||||
|
Files.Add(current);
|
||||||
|
oldLine = 0; newLine = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current == null) continue;
|
||||||
|
|
||||||
|
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// e.g. "@@ -10,7 +10,9 @@"
|
||||||
|
ParseHunkHeader(line, out oldLine, out newLine);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip diff metadata lines
|
||||||
|
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("index ", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("new file", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("deleted file", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("Binary ", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (line.StartsWith('+'))
|
||||||
|
{
|
||||||
|
current.Lines.Add(new DiffLineViewModel
|
||||||
|
{
|
||||||
|
Kind = DiffLineKind.Add,
|
||||||
|
NewNo = newLine++,
|
||||||
|
Text = line.Length > 1 ? line[1..] : "",
|
||||||
|
});
|
||||||
|
current.Additions++;
|
||||||
|
}
|
||||||
|
else if (line.StartsWith('-'))
|
||||||
|
{
|
||||||
|
current.Lines.Add(new DiffLineViewModel
|
||||||
|
{
|
||||||
|
Kind = DiffLineKind.Del,
|
||||||
|
OldNo = oldLine++,
|
||||||
|
Text = line.Length > 1 ? line[1..] : "",
|
||||||
|
});
|
||||||
|
current.Deletions++;
|
||||||
|
}
|
||||||
|
else if (line.StartsWith(' '))
|
||||||
|
{
|
||||||
|
current.Lines.Add(new DiffLineViewModel
|
||||||
|
{
|
||||||
|
Kind = DiffLineKind.Ctx,
|
||||||
|
OldNo = oldLine++,
|
||||||
|
NewNo = newLine++,
|
||||||
|
Text = line.Length > 1 ? line[1..] : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedFile = Files.Count > 0 ? Files[0] : null;
|
||||||
|
if (Files.Count == 0) StatusMessage = "No changes to show.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseHunkHeader(string header, out int oldStart, out int newStart)
|
||||||
|
{
|
||||||
|
oldStart = 1; newStart = 1;
|
||||||
|
// Format: @@ -<old>,<count> +<new>,<count> @@
|
||||||
|
var at = header.IndexOf("@@", 3, StringComparison.Ordinal);
|
||||||
|
var inner = at > 0 ? header[3..at].Trim() : header;
|
||||||
|
var segs = inner.Split(' ');
|
||||||
|
foreach (var seg in segs)
|
||||||
|
{
|
||||||
|
if (seg.StartsWith('-') && int.TryParse(seg[1..].Split(',')[0], out var o))
|
||||||
|
oldStart = o;
|
||||||
|
else if (seg.StartsWith('+') && int.TryParse(seg[1..].Split(',')[0], out var n))
|
||||||
|
newStart = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly WorkerClient _worker;
|
||||||
|
|
||||||
|
public string ListId { get; set; } = "";
|
||||||
|
|
||||||
|
[ObservableProperty] private string _name = "";
|
||||||
|
[ObservableProperty] private string _workingDir = "";
|
||||||
|
[ObservableProperty] private string _defaultCommitType = "chore";
|
||||||
|
|
||||||
|
[ObservableProperty] private string _selectedModel = "(default)";
|
||||||
|
[ObservableProperty] private string _systemPrompt = "";
|
||||||
|
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||||
|
|
||||||
|
public ObservableCollection<string> ModelOptions { get; } = new()
|
||||||
|
{
|
||||||
|
"(default)", "sonnet", "opus", "haiku",
|
||||||
|
};
|
||||||
|
|
||||||
|
public ObservableCollection<string> CommitTypeOptions { get; } = new()
|
||||||
|
{
|
||||||
|
"chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
|
||||||
|
};
|
||||||
|
|
||||||
|
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
||||||
|
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
public ListSettingsModalViewModel(WorkerClient worker)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAsync(
|
||||||
|
string listId,
|
||||||
|
string name,
|
||||||
|
string? workingDir,
|
||||||
|
string defaultCommitType,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ListId = listId;
|
||||||
|
Name = name;
|
||||||
|
WorkingDir = workingDir ?? "";
|
||||||
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? "chore" : defaultCommitType;
|
||||||
|
|
||||||
|
Agents.Clear();
|
||||||
|
Agents.Add(new AgentInfo("(none)", "", ""));
|
||||||
|
var agents = await _worker.GetAgentsAsync();
|
||||||
|
foreach (var a in agents) Agents.Add(a);
|
||||||
|
|
||||||
|
var config = await _worker.GetListConfigAsync(listId);
|
||||||
|
SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? "(default)" : config!.Model!;
|
||||||
|
SystemPrompt = config?.SystemPrompt ?? "";
|
||||||
|
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
|
||||||
|
? Agents[0]
|
||||||
|
: (Agents.FirstOrDefault(a => a.Path == config!.AgentPath) ?? Agents[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
var model = SelectedModel == "(default)" ? null : SelectedModel;
|
||||||
|
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
|
||||||
|
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
|
||||||
|
|
||||||
|
await _worker.UpdateListAsync(new UpdateListDto(
|
||||||
|
ListId,
|
||||||
|
string.IsNullOrWhiteSpace(Name) ? "Untitled" : Name,
|
||||||
|
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||||
|
DefaultCommitType));
|
||||||
|
|
||||||
|
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(
|
||||||
|
ListId, model, sp, ap));
|
||||||
|
|
||||||
|
CloseAction?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel() => CloseAction?.Invoke();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ResetAgentSettings()
|
||||||
|
{
|
||||||
|
SelectedModel = "(default)";
|
||||||
|
SystemPrompt = "";
|
||||||
|
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
Normal file
117
src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public sealed partial class MergeModalViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly WorkerClient _worker;
|
||||||
|
|
||||||
|
public string TaskId { get; set; } = "";
|
||||||
|
public string TaskTitle { get; set; } = "";
|
||||||
|
|
||||||
|
public ObservableCollection<string> Branches { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _selectedBranch;
|
||||||
|
[ObservableProperty] private bool _removeWorktree = true;
|
||||||
|
[ObservableProperty] private string _commitMessage = "";
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string? _errorMessage;
|
||||||
|
[ObservableProperty] private string? _warningMessage;
|
||||||
|
[ObservableProperty] private string? _successMessage;
|
||||||
|
[ObservableProperty] private bool _hasConflict;
|
||||||
|
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
||||||
|
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
public MergeModalViewModel(WorkerClient worker)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync(string taskId, string taskTitle)
|
||||||
|
{
|
||||||
|
TaskId = taskId;
|
||||||
|
TaskTitle = taskTitle;
|
||||||
|
CommitMessage = $"Merge task: {taskTitle}";
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targets = await _worker.GetMergeTargetsAsync(taskId);
|
||||||
|
Branches.Clear();
|
||||||
|
if (targets is null)
|
||||||
|
{
|
||||||
|
ErrorMessage = "Worker offline — cannot list branches.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (var b in targets.LocalBranches) Branches.Add(b);
|
||||||
|
SelectedBranch = Branches.Contains(targets.DefaultBranch)
|
||||||
|
? targets.DefaultBranch
|
||||||
|
: Branches.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorMessage = $"Failed to load branches: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanSubmit() =>
|
||||||
|
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||||
|
private async Task SubmitAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(SelectedBranch)) return;
|
||||||
|
IsBusy = true;
|
||||||
|
ErrorMessage = null;
|
||||||
|
WarningMessage = null;
|
||||||
|
SuccessMessage = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.MergeTaskAsync(
|
||||||
|
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);
|
||||||
|
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case "merged":
|
||||||
|
SuccessMessage = result.ErrorMessage is not null
|
||||||
|
? $"Merged with warning: {result.ErrorMessage}"
|
||||||
|
: "Merged.";
|
||||||
|
// Auto-close after a short delay.
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(1200);
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => CloseAction?.Invoke());
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "conflict":
|
||||||
|
HasConflict = true;
|
||||||
|
ConflictFiles = result.ConflictFiles;
|
||||||
|
ErrorMessage = "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.";
|
||||||
|
break;
|
||||||
|
case "blocked":
|
||||||
|
ErrorMessage = $"Blocked: {result.ErrorMessage}";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ErrorMessage = $"Unknown status: {result.Status}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorMessage = $"Merge failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel() => CloseAction?.Invoke();
|
||||||
|
}
|
||||||
202
src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs
Normal file
202
src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly WorkerClient _worker;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
||||||
|
[ObservableProperty] private string _defaultModel = "sonnet";
|
||||||
|
[ObservableProperty] private int _defaultMaxTurns = 30;
|
||||||
|
[ObservableProperty] private string _defaultPermissionMode = "bypassPermissions";
|
||||||
|
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
||||||
|
[ObservableProperty] private string? _centralWorktreeRoot;
|
||||||
|
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
|
||||||
|
[ObservableProperty] private int _worktreeAutoCleanupDays = 7;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private bool _showResetConfirm;
|
||||||
|
[ObservableProperty] private string _validationError = "";
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
|
||||||
|
public IReadOnlyList<string> PermissionModes { get; } = new[]
|
||||||
|
{ "bypassPermissions", "acceptEdits", "plan", "default" };
|
||||||
|
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||||
|
|
||||||
|
public string AppVersion { get; } =
|
||||||
|
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||||
|
|
||||||
|
public string DataFolderPath { get; } = Paths.AppDataRoot();
|
||||||
|
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
||||||
|
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||||
|
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
public SettingsModalViewModel(WorkerClient worker)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = await _worker.GetAppSettingsAsync();
|
||||||
|
if (dto is not null)
|
||||||
|
{
|
||||||
|
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
|
||||||
|
DefaultModel = dto.DefaultModel ?? "sonnet";
|
||||||
|
DefaultMaxTurns = dto.DefaultMaxTurns;
|
||||||
|
DefaultPermissionMode = dto.DefaultPermissionMode ?? "bypassPermissions";
|
||||||
|
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
||||||
|
CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
||||||
|
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
||||||
|
WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusMessage = "Worker offline — settings read-only.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Validate()
|
||||||
|
{
|
||||||
|
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
|
||||||
|
{ ValidationError = "Max turns must be between 1 and 200."; return false; }
|
||||||
|
|
||||||
|
if (WorktreeAutoCleanupEnabled &&
|
||||||
|
(WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365))
|
||||||
|
{ ValidationError = "Cleanup days must be between 1 and 365."; return false; }
|
||||||
|
|
||||||
|
if (WorktreeStrategy == "central")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(CentralWorktreeRoot))
|
||||||
|
{ ValidationError = "Central worktree root is required for Central strategy."; return false; }
|
||||||
|
if (!Directory.Exists(CentralWorktreeRoot))
|
||||||
|
{ ValidationError = $"Directory not found: {CentralWorktreeRoot}"; return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
if (!Validate()) return;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = new AppSettingsDto(
|
||||||
|
DefaultClaudeInstructions ?? "",
|
||||||
|
DefaultModel ?? "sonnet",
|
||||||
|
DefaultMaxTurns,
|
||||||
|
DefaultPermissionMode ?? "bypassPermissions",
|
||||||
|
WorktreeStrategy ?? "sibling",
|
||||||
|
string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot,
|
||||||
|
WorktreeAutoCleanupEnabled,
|
||||||
|
WorktreeAutoCleanupDays);
|
||||||
|
await _worker.UpdateAppSettingsAsync(dto);
|
||||||
|
CloseAction?.Invoke();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Save failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel() => CloseAction?.Invoke();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CleanupWorktrees()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.CleanupFinishedWorktreesAsync();
|
||||||
|
StatusMessage = result is null
|
||||||
|
? "Worker offline."
|
||||||
|
: $"Removed {result.Removed} worktree(s).";
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RequestResetConfirm() => ShowResetConfirm = true;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CancelResetConfirm() => ShowResetConfirm = false;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ConfirmResetAll()
|
||||||
|
{
|
||||||
|
ShowResetConfirm = false;
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.ResetAllWorktreesAsync();
|
||||||
|
if (result is null)
|
||||||
|
StatusMessage = "Worker offline.";
|
||||||
|
else if (result.Blocked)
|
||||||
|
StatusMessage = $"Cannot force-remove: {result.RunningTasks} task(s) still running. Cancel them first.";
|
||||||
|
else
|
||||||
|
StatusMessage = $"Removed {result.Removed} worktree(s) from {result.TasksAffected} task(s).";
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenPath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null);
|
||||||
|
if (target is null) return;
|
||||||
|
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs
Normal file
86
src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public sealed partial class WorktreeNodeViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public string? Status { get; init; }
|
||||||
|
public bool IsDirectory { get; init; }
|
||||||
|
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly GitService _git;
|
||||||
|
|
||||||
|
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _worktreePath = "";
|
||||||
|
|
||||||
|
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
public WorktreeModalViewModel(GitService git)
|
||||||
|
{
|
||||||
|
_git = git;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Close() => CloseAction?.Invoke();
|
||||||
|
|
||||||
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Root.Clear();
|
||||||
|
|
||||||
|
string stdout;
|
||||||
|
try { stdout = await _git.GetStatusPorcelainAsync(WorktreePath, ct); }
|
||||||
|
catch { return; }
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(stdout)) return;
|
||||||
|
|
||||||
|
var dirs = new Dictionary<string, WorktreeNodeViewModel>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (line.Length < 4) continue;
|
||||||
|
|
||||||
|
// porcelain format: XY<space>path (XY = two-char status)
|
||||||
|
var xy = line[..2];
|
||||||
|
// Pick staged char first, fall back to unstaged
|
||||||
|
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
|
||||||
|
var status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||||
|
var path = line[3..].Trim().Replace('\\', '/');
|
||||||
|
|
||||||
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (segments.Length == 0) continue;
|
||||||
|
|
||||||
|
WorktreeNodeViewModel? parent = null;
|
||||||
|
var accumulated = "";
|
||||||
|
for (var i = 0; i < segments.Length - 1; i++)
|
||||||
|
{
|
||||||
|
accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i];
|
||||||
|
if (!dirs.TryGetValue(accumulated, out var dir))
|
||||||
|
{
|
||||||
|
dir = new WorktreeNodeViewModel { Name = segments[i], IsDirectory = true };
|
||||||
|
dirs[accumulated] = dir;
|
||||||
|
if (parent == null) Root.Add(dir);
|
||||||
|
else parent.Children.Add(dir);
|
||||||
|
}
|
||||||
|
parent = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaf = new WorktreeNodeViewModel
|
||||||
|
{
|
||||||
|
Name = segments[^1],
|
||||||
|
Status = status,
|
||||||
|
IsDirectory = false
|
||||||
|
};
|
||||||
|
if (parent == null) Root.Add(leaf);
|
||||||
|
else parent.Children.Add(leaf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using System.Collections.Specialized;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class StatusBarViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
private readonly WorkerClient _worker;
|
|
||||||
|
|
||||||
[ObservableProperty] private string _connectionStatus = "Offline";
|
|
||||||
[ObservableProperty] private string _activeTasksSummary = "";
|
|
||||||
[ObservableProperty] private string _statusMessage = "";
|
|
||||||
|
|
||||||
public StatusBarViewModel(WorkerClient worker)
|
|
||||||
{
|
|
||||||
_worker = worker;
|
|
||||||
|
|
||||||
worker.PropertyChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.PropertyName == nameof(WorkerClient.IsConnected) ||
|
|
||||||
e.PropertyName == nameof(WorkerClient.IsReconnecting))
|
|
||||||
{
|
|
||||||
ConnectionStatus = worker.IsConnected ? "Online"
|
|
||||||
: worker.IsReconnecting ? "Connecting..."
|
|
||||||
: "Offline";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.ActiveTasks.CollectionChanged += OnActiveTasksChanged;
|
|
||||||
RefreshActiveSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnActiveTasksChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
|
||||||
RefreshActiveSummary();
|
|
||||||
|
|
||||||
private void RefreshActiveSummary()
|
|
||||||
{
|
|
||||||
if (_worker.ActiveTasks.Count == 0)
|
|
||||||
{
|
|
||||||
ActiveTasksSummary = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parts = _worker.ActiveTasks
|
|
||||||
.Select(t => $"{t.Slot}: {Shorten(t.TaskId)}")
|
|
||||||
.ToList();
|
|
||||||
ActiveTasksSummary = string.Join(" | ", parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Shorten(string id) =>
|
|
||||||
id.Length > 8 ? id[..8] : id;
|
|
||||||
|
|
||||||
public void ShowMessage(string msg) => StatusMessage = msg;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using ClaudeDo.Data.Models;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class SubtaskItemViewModel : ObservableObject
|
|
||||||
{
|
|
||||||
[ObservableProperty] private string _title = string.Empty;
|
|
||||||
[ObservableProperty] private bool _completed;
|
|
||||||
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
public string? OriginalTitle { get; set; }
|
|
||||||
public bool OriginalCompleted { get; set; }
|
|
||||||
|
|
||||||
public static SubtaskItemViewModel From(SubtaskEntity e) => new()
|
|
||||||
{
|
|
||||||
Id = e.Id,
|
|
||||||
Title = e.Title,
|
|
||||||
Completed = e.Completed,
|
|
||||||
OriginalTitle = e.Title,
|
|
||||||
OriginalCompleted = e.Completed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Git;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Ui.Helpers;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class TaskDetailViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
||||||
private readonly GitService _git;
|
|
||||||
private readonly WorkerClient _worker;
|
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
|
||||||
[ObservableProperty] private string? _description;
|
|
||||||
[ObservableProperty] private string? _result;
|
|
||||||
[ObservableProperty] private string? _logPath;
|
|
||||||
[ObservableProperty] private string _statusText = "";
|
|
||||||
[ObservableProperty] private string _statusChoice = "Manual";
|
|
||||||
[ObservableProperty] private string _commitType = "chore";
|
|
||||||
[ObservableProperty] private string _modelChoice = "(list default)";
|
|
||||||
[ObservableProperty] private string? _systemPromptOverride;
|
|
||||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
|
||||||
public List<AgentInfo> AvailableAgents { get; } = [];
|
|
||||||
|
|
||||||
public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"];
|
|
||||||
public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
|
|
||||||
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
|
||||||
|
|
||||||
// Worktree
|
|
||||||
[ObservableProperty] private bool _hasWorktree;
|
|
||||||
[ObservableProperty] private string? _branchName;
|
|
||||||
[ObservableProperty] private string? _diffStat;
|
|
||||||
[ObservableProperty] private string? _worktreePath;
|
|
||||||
[ObservableProperty] private string _worktreeState = "";
|
|
||||||
|
|
||||||
// Live stream
|
|
||||||
[ObservableProperty] private string _liveText = "";
|
|
||||||
private StreamLineFormatter _formatter = new();
|
|
||||||
public ObservableCollection<TagEntity> Tags { get; } = new();
|
|
||||||
[ObservableProperty] private string _newTagInput = "";
|
|
||||||
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
|
||||||
|
|
||||||
private string? _taskId;
|
|
||||||
private string? _listId;
|
|
||||||
private bool _isLoading;
|
|
||||||
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
|
|
||||||
// before the previous load finished — prevents torn state on _taskId,
|
|
||||||
// Subtasks, Tags, etc.
|
|
||||||
private CancellationTokenSource? _loadCts;
|
|
||||||
|
|
||||||
public event Action<string>? TaskChanged;
|
|
||||||
|
|
||||||
public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, GitService git, WorkerClient worker)
|
|
||||||
{
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_git = git;
|
|
||||||
_worker = worker;
|
|
||||||
|
|
||||||
worker.TaskMessageEvent += OnTaskMessage;
|
|
||||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
|
||||||
worker.TaskUpdatedEvent += OnTaskUpdated;
|
|
||||||
worker.RunNowRequestedEvent += OnRunNowRequested;
|
|
||||||
worker.TaskStartedEvent += OnTaskStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LoadAsync(string taskId)
|
|
||||||
{
|
|
||||||
// Cancel any in-flight load so rapid TaskUpdated events don't race
|
|
||||||
// on _taskId / Subtasks / Tags. The newest caller wins.
|
|
||||||
var oldCts = _loadCts;
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
_loadCts = cts;
|
|
||||||
oldCts?.Cancel();
|
|
||||||
oldCts?.Dispose();
|
|
||||||
var ct = cts.Token;
|
|
||||||
|
|
||||||
_taskId = taskId;
|
|
||||||
HasWorktree = false;
|
|
||||||
WorktreeState = "";
|
|
||||||
BranchName = null;
|
|
||||||
DiffStat = null;
|
|
||||||
WorktreePath = null;
|
|
||||||
OnPropertyChanged(nameof(CanWorktreeAction));
|
|
||||||
LiveText = "";
|
|
||||||
_formatter = new StreamLineFormatter();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TaskEntity? task;
|
|
||||||
List<TagEntity> tags;
|
|
||||||
List<SubtaskEntity> subtasks;
|
|
||||||
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
task = await taskRepo.GetByIdAsync(taskId, ct);
|
|
||||||
if (task is null) return;
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
tags = await taskRepo.GetTagsAsync(taskId, ct);
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var subtaskRepo = new SubtaskRepository(context);
|
|
||||||
subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
if (AvailableAgents.Count == 0)
|
|
||||||
{
|
|
||||||
var agents = await _worker.GetAgentsAsync();
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
AvailableAgents.AddRange(agents);
|
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
|
||||||
}
|
|
||||||
|
|
||||||
_isLoading = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_listId = task.ListId;
|
|
||||||
Title = task.Title;
|
|
||||||
Description = task.Description;
|
|
||||||
Result = task.Result;
|
|
||||||
LogPath = task.LogPath;
|
|
||||||
if (task.LogPath is not null
|
|
||||||
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
|
|
||||||
&& File.Exists(task.LogPath))
|
|
||||||
{
|
|
||||||
_formatter = new StreamLineFormatter();
|
|
||||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
|
|
||||||
}
|
|
||||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
|
||||||
StatusChoice = task.Status.ToString();
|
|
||||||
CommitType = task.CommitType;
|
|
||||||
ModelChoice = task.Model is not null
|
|
||||||
? ListEditorViewModel.ModelIdToDisplay(task.Model)
|
|
||||||
: "(list default)";
|
|
||||||
SystemPromptOverride = task.SystemPrompt;
|
|
||||||
if (task.AgentPath is not null)
|
|
||||||
{
|
|
||||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
|
|
||||||
if (match is null)
|
|
||||||
{
|
|
||||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
|
|
||||||
AvailableAgents.Add(match);
|
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
|
||||||
}
|
|
||||||
SelectedAgent = match;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SelectedAgent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Tags.Clear();
|
|
||||||
foreach (var tag in tags)
|
|
||||||
Tags.Add(tag);
|
|
||||||
|
|
||||||
// Tear down old subtask subscriptions before replacing them.
|
|
||||||
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
|
||||||
Subtasks.Clear();
|
|
||||||
foreach (var s in subtasks)
|
|
||||||
{
|
|
||||||
var vm = SubtaskItemViewModel.From(s);
|
|
||||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
|
||||||
Subtasks.Add(vm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await LoadWorktreeAsync(taskId);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Superseded by a newer LoadAsync — nothing to do.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SaveAsync()
|
|
||||||
{
|
|
||||||
if (_isLoading || _taskId is null) return;
|
|
||||||
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
var entity = await taskRepo.GetByIdAsync(_taskId);
|
|
||||||
if (entity is null) return;
|
|
||||||
|
|
||||||
entity.Title = Title;
|
|
||||||
entity.Description = Description;
|
|
||||||
entity.CommitType = CommitType;
|
|
||||||
entity.Model = ModelChoice != "(list default)"
|
|
||||||
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
|
|
||||||
: null;
|
|
||||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
|
||||||
entity.AgentPath = SelectedAgent?.Path;
|
|
||||||
|
|
||||||
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
|
||||||
entity.Status = status;
|
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(entity);
|
|
||||||
StatusText = entity.Status.ToString().ToLowerInvariant();
|
|
||||||
TaskChanged?.Invoke(_taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task AddTag()
|
|
||||||
{
|
|
||||||
var name = NewTagInput.Trim();
|
|
||||||
if (string.IsNullOrEmpty(name) || _taskId is null) return;
|
|
||||||
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var tagRepo = new TagRepository(context);
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
|
|
||||||
var tagId = await tagRepo.GetOrCreateAsync(name);
|
|
||||||
await taskRepo.AddTagAsync(_taskId, tagId);
|
|
||||||
|
|
||||||
Tags.Clear();
|
|
||||||
var tags = await taskRepo.GetTagsAsync(_taskId);
|
|
||||||
foreach (var tag in tags)
|
|
||||||
Tags.Add(tag);
|
|
||||||
|
|
||||||
NewTagInput = "";
|
|
||||||
TaskChanged?.Invoke(_taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task RemoveTag(TagEntity tag)
|
|
||||||
{
|
|
||||||
if (_taskId is null) return;
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
await taskRepo.RemoveTagAsync(_taskId, tag.Id);
|
|
||||||
Tags.Remove(tag);
|
|
||||||
TaskChanged?.Invoke(_taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task AddSubtask()
|
|
||||||
{
|
|
||||||
if (_taskId is null) return;
|
|
||||||
var entity = new SubtaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
TaskId = _taskId,
|
|
||||||
Title = "",
|
|
||||||
Completed = false,
|
|
||||||
OrderNum = Subtasks.Count,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var subtaskRepo = new SubtaskRepository(context);
|
|
||||||
await subtaskRepo.AddAsync(entity);
|
|
||||||
var vm = SubtaskItemViewModel.From(entity);
|
|
||||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
|
||||||
Subtasks.Add(vm);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task RemoveSubtask(SubtaskItemViewModel item)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(item.Id))
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var subtaskRepo = new SubtaskRepository(context);
|
|
||||||
await subtaskRepo.DeleteAsync(item.Id);
|
|
||||||
}
|
|
||||||
item.PropertyChanged -= OnSubtaskPropertyChanged;
|
|
||||||
Subtasks.Remove(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
|
|
||||||
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_taskId is null) return;
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var orig = await context.Subtasks.AsNoTracking().FirstOrDefaultAsync(s => s.Id == vm.Id);
|
|
||||||
var subtaskRepo = new SubtaskRepository(context);
|
|
||||||
await subtaskRepo.UpdateAsync(new SubtaskEntity
|
|
||||||
{
|
|
||||||
Id = vm.Id,
|
|
||||||
TaskId = _taskId,
|
|
||||||
Title = vm.Title,
|
|
||||||
Completed = vm.Completed,
|
|
||||||
OrderNum = Subtasks.IndexOf(vm),
|
|
||||||
CreatedAt = orig?.CreatedAt ?? DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// async void must never throw — surface via Debug.
|
|
||||||
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetAgentFromPath(string path)
|
|
||||||
{
|
|
||||||
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
|
||||||
if (existing is null)
|
|
||||||
{
|
|
||||||
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
|
||||||
AvailableAgents.Add(existing);
|
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
|
||||||
}
|
|
||||||
SelectedAgent = existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
// Cancel any load in flight so it doesn't resurrect state after Clear.
|
|
||||||
_loadCts?.Cancel();
|
|
||||||
_loadCts?.Dispose();
|
|
||||||
_loadCts = null;
|
|
||||||
|
|
||||||
_taskId = null;
|
|
||||||
_listId = null;
|
|
||||||
Title = "";
|
|
||||||
Description = null;
|
|
||||||
Result = null;
|
|
||||||
LogPath = null;
|
|
||||||
StatusText = "";
|
|
||||||
HasWorktree = false;
|
|
||||||
LiveText = "";
|
|
||||||
_formatter = new StreamLineFormatter();
|
|
||||||
Tags.Clear();
|
|
||||||
NewTagInput = "";
|
|
||||||
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
|
|
||||||
Subtasks.Clear();
|
|
||||||
StatusChoice = "Manual";
|
|
||||||
CommitType = "chore";
|
|
||||||
ModelChoice = "(list default)";
|
|
||||||
SystemPromptOverride = null;
|
|
||||||
SelectedAgent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadWorktreeAsync(string taskId)
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var wtRepo = new WorktreeRepository(context);
|
|
||||||
var wt = await wtRepo.GetByTaskIdAsync(taskId);
|
|
||||||
HasWorktree = wt is not null;
|
|
||||||
if (wt is not null)
|
|
||||||
{
|
|
||||||
BranchName = wt.BranchName;
|
|
||||||
DiffStat = wt.DiffStat;
|
|
||||||
WorktreePath = wt.Path;
|
|
||||||
WorktreeState = wt.State.ToString().ToLowerInvariant();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
BranchName = null;
|
|
||||||
DiffStat = null;
|
|
||||||
WorktreePath = null;
|
|
||||||
WorktreeState = "";
|
|
||||||
}
|
|
||||||
OnPropertyChanged(nameof(CanWorktreeAction));
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CanWorktreeAction => HasWorktree && WorktreeState == "active";
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void OpenWorktree()
|
|
||||||
{
|
|
||||||
if (WorktreePath is null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = WorktreePath,
|
|
||||||
UseShellExecute = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"Failed to open worktree: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ShowDiff()
|
|
||||||
{
|
|
||||||
if (WorktreePath is null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "cmd.exe",
|
|
||||||
Arguments = $"/k git -C \"{WorktreePath}\" diff HEAD~1",
|
|
||||||
UseShellExecute = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"Failed to show diff: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task MergeIntoMainAsync()
|
|
||||||
{
|
|
||||||
if (_taskId is null || _listId is null) return;
|
|
||||||
|
|
||||||
WorktreeEntity? wt;
|
|
||||||
ListEntity? list;
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var wtRepo = new WorktreeRepository(context);
|
|
||||||
wt = await wtRepo.GetByTaskIdAsync(_taskId);
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
list = await listRepo.GetByIdAsync(_listId);
|
|
||||||
}
|
|
||||||
if (wt is null || list?.WorkingDir is null) return;
|
|
||||||
|
|
||||||
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
|
|
||||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
|
||||||
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
|
|
||||||
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var wtRepo = new WorktreeRepository(context);
|
|
||||||
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
|
|
||||||
}
|
|
||||||
await LoadWorktreeAsync(_taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task KeepAsBranchAsync()
|
|
||||||
{
|
|
||||||
if (_taskId is null || _listId is null) return;
|
|
||||||
|
|
||||||
WorktreeEntity? wt;
|
|
||||||
ListEntity? list;
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var wtRepo = new WorktreeRepository(context);
|
|
||||||
wt = await wtRepo.GetByTaskIdAsync(_taskId);
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
list = await listRepo.GetByIdAsync(_listId);
|
|
||||||
}
|
|
||||||
if (wt is null || list?.WorkingDir is null) return;
|
|
||||||
|
|
||||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
|
||||||
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var wtRepo = new WorktreeRepository(context);
|
|
||||||
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
|
|
||||||
}
|
|
||||||
await LoadWorktreeAsync(_taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task DiscardAsync()
|
|
||||||
{
|
|
||||||
if (_taskId is null || _listId is null) return;
|
|
||||||
|
|
||||||
WorktreeEntity? wt;
|
|
||||||
ListEntity? list;
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var wtRepo = new WorktreeRepository(context);
|
|
||||||
wt = await wtRepo.GetByTaskIdAsync(_taskId);
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
list = await listRepo.GetByIdAsync(_listId);
|
|
||||||
}
|
|
||||||
if (wt is null || list?.WorkingDir is null) return;
|
|
||||||
|
|
||||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
|
||||||
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
|
|
||||||
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var wtRepo = new WorktreeRepository(context);
|
|
||||||
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
|
|
||||||
}
|
|
||||||
await LoadWorktreeAsync(_taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTaskMessage(string taskId, string line)
|
|
||||||
{
|
|
||||||
if (taskId != _taskId) return;
|
|
||||||
var formatted = _formatter.FormatLine(line);
|
|
||||||
if (formatted is not null)
|
|
||||||
{
|
|
||||||
LiveText += formatted;
|
|
||||||
if (LiveText.Length > 50_000)
|
|
||||||
LiveText = StreamLineFormatter.Trim(LiveText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRunNowRequested(string taskId)
|
|
||||||
{
|
|
||||||
if (taskId != _taskId) return;
|
|
||||||
StatusText = "starting...";
|
|
||||||
LiveText = "";
|
|
||||||
_formatter = new StreamLineFormatter();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTaskStarted(string slot, string taskId, DateTime startedAt)
|
|
||||||
{
|
|
||||||
if (taskId != _taskId) return;
|
|
||||||
StatusText = "running";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnWorktreeUpdated(string taskId)
|
|
||||||
{
|
|
||||||
if (taskId != _taskId) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await LoadWorktreeAsync(taskId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// async void must never throw.
|
|
||||||
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnTaskUpdated(string taskId)
|
|
||||||
{
|
|
||||||
if (taskId != _taskId) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await LoadAsync(taskId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// async void must never throw.
|
|
||||||
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.IO;
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class TaskEditorViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
|
||||||
[ObservableProperty] private string? _description;
|
|
||||||
[ObservableProperty] private string _commitType = "chore";
|
|
||||||
[ObservableProperty] private string _statusChoice = "manual";
|
|
||||||
[ObservableProperty] private string _tagsInput = "";
|
|
||||||
[ObservableProperty] private string _windowTitle = "New Task";
|
|
||||||
[ObservableProperty] private string _modelChoice = "(list default)";
|
|
||||||
[ObservableProperty] private string? _systemPromptOverride;
|
|
||||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
|
||||||
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
|
||||||
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
|
||||||
|
|
||||||
private string? _editId;
|
|
||||||
private string _listId = "";
|
|
||||||
private DateTime _createdAt;
|
|
||||||
private TaskCompletionSource<TaskEntity?> _tcs = new();
|
|
||||||
|
|
||||||
public event Action? RequestClose;
|
|
||||||
|
|
||||||
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
|
||||||
|
|
||||||
public static string[] CommitTypes { get; } =
|
|
||||||
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
|
|
||||||
|
|
||||||
public static string[] StatusChoices { get; } =
|
|
||||||
["manual", "queued"];
|
|
||||||
|
|
||||||
public TaskEditorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
|
||||||
{
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
|
||||||
{
|
|
||||||
AvailableAgents = await worker.GetAgentsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetAgentFromPath(string path)
|
|
||||||
{
|
|
||||||
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
|
||||||
if (existing is null)
|
|
||||||
{
|
|
||||||
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
|
||||||
AvailableAgents.Add(existing);
|
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
|
||||||
}
|
|
||||||
SelectedAgent = existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<string> SelectedTagNames =>
|
|
||||||
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
public void InitForCreate(string listId, string defaultCommitType = "chore")
|
|
||||||
{
|
|
||||||
_tcs = new TaskCompletionSource<TaskEntity?>();
|
|
||||||
_editId = null;
|
|
||||||
_listId = listId;
|
|
||||||
_createdAt = DateTime.UtcNow;
|
|
||||||
CommitType = defaultCommitType;
|
|
||||||
WindowTitle = "New Task";
|
|
||||||
Subtasks.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
_tcs = new TaskCompletionSource<TaskEntity?>();
|
|
||||||
_editId = entity.Id;
|
|
||||||
_listId = entity.ListId;
|
|
||||||
_createdAt = entity.CreatedAt;
|
|
||||||
Title = entity.Title;
|
|
||||||
Description = entity.Description;
|
|
||||||
CommitType = entity.CommitType;
|
|
||||||
StatusChoice = entity.Status switch
|
|
||||||
{
|
|
||||||
TaskStatus.Manual => "manual",
|
|
||||||
TaskStatus.Queued => "queued",
|
|
||||||
_ => entity.Status.ToString().ToLowerInvariant(),
|
|
||||||
};
|
|
||||||
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
|
|
||||||
ModelChoice = entity.Model is not null
|
|
||||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
|
||||||
: "(list default)";
|
|
||||||
SystemPromptOverride = entity.SystemPrompt;
|
|
||||||
|
|
||||||
if (entity.AgentPath is not null)
|
|
||||||
{
|
|
||||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
|
||||||
if (match is null)
|
|
||||||
{
|
|
||||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
|
||||||
AvailableAgents.Add(match);
|
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
|
||||||
}
|
|
||||||
SelectedAgent = match;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SelectedAgent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowTitle = $"Edit Task: {entity.Title}";
|
|
||||||
|
|
||||||
Subtasks.Clear();
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var subtaskRepo = new SubtaskRepository(context);
|
|
||||||
var list = await subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
|
|
||||||
foreach (var s in list)
|
|
||||||
Subtasks.Add(SubtaskItemViewModel.From(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep old sync overload for callers that haven't loaded agents yet
|
|
||||||
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
|
|
||||||
{
|
|
||||||
_tcs = new TaskCompletionSource<TaskEntity?>();
|
|
||||||
_editId = entity.Id;
|
|
||||||
_listId = entity.ListId;
|
|
||||||
_createdAt = entity.CreatedAt;
|
|
||||||
Title = entity.Title;
|
|
||||||
Description = entity.Description;
|
|
||||||
CommitType = entity.CommitType;
|
|
||||||
StatusChoice = entity.Status switch
|
|
||||||
{
|
|
||||||
TaskStatus.Manual => "manual",
|
|
||||||
TaskStatus.Queued => "queued",
|
|
||||||
_ => entity.Status.ToString().ToLowerInvariant(),
|
|
||||||
};
|
|
||||||
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
|
|
||||||
ModelChoice = entity.Model is not null
|
|
||||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
|
||||||
: "(list default)";
|
|
||||||
SystemPromptOverride = entity.SystemPrompt;
|
|
||||||
|
|
||||||
if (entity.AgentPath is not null)
|
|
||||||
{
|
|
||||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
|
||||||
if (match is null)
|
|
||||||
{
|
|
||||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
|
||||||
AvailableAgents.Add(match);
|
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
|
||||||
}
|
|
||||||
SelectedAgent = match;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SelectedAgent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowTitle = $"Edit Task: {entity.Title}";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void AddSubtask() => Subtasks.Add(new SubtaskItemViewModel());
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void RemoveSubtask(SubtaskItemViewModel item) => Subtasks.Remove(item);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task Save()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(Title)) return;
|
|
||||||
var status = StatusChoice switch
|
|
||||||
{
|
|
||||||
"queued" => TaskStatus.Queued,
|
|
||||||
_ => TaskStatus.Manual,
|
|
||||||
};
|
|
||||||
var taskId = _editId ?? Guid.NewGuid().ToString();
|
|
||||||
var entity = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = taskId,
|
|
||||||
ListId = _listId,
|
|
||||||
Title = Title.Trim(),
|
|
||||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
|
||||||
Status = status,
|
|
||||||
CommitType = CommitType,
|
|
||||||
CreatedAt = _createdAt,
|
|
||||||
};
|
|
||||||
entity.Model = ModelChoice != "(list default)"
|
|
||||||
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
|
|
||||||
: null;
|
|
||||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
|
||||||
entity.AgentPath = SelectedAgent?.Path;
|
|
||||||
|
|
||||||
// Persist subtask changes
|
|
||||||
if (_editId is not null)
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var subtaskRepo = new SubtaskRepository(context);
|
|
||||||
var existing = await subtaskRepo.GetByTaskIdAsync(taskId);
|
|
||||||
var existingIds = existing.Select(s => s.Id).ToHashSet();
|
|
||||||
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
|
|
||||||
|
|
||||||
// Deleted
|
|
||||||
foreach (var id in existingIds.Except(currentIds))
|
|
||||||
await subtaskRepo.DeleteAsync(id);
|
|
||||||
|
|
||||||
// Updated
|
|
||||||
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
|
|
||||||
{
|
|
||||||
if (vm.Id == "") continue;
|
|
||||||
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
|
|
||||||
{
|
|
||||||
var origSub = existing.FirstOrDefault(e => e.Id == vm.Id);
|
|
||||||
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = origSub?.CreatedAt ?? DateTime.UtcNow });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// update order_num if position changed
|
|
||||||
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
|
|
||||||
if (orig is not null && orig.OrderNum != idx)
|
|
||||||
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Added (id == "" means new)
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var subtaskRepo = new SubtaskRepository(context);
|
|
||||||
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
|
|
||||||
var newId = Guid.NewGuid().ToString();
|
|
||||||
await subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_tcs.TrySetResult(entity);
|
|
||||||
RequestClose?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void Cancel()
|
|
||||||
{
|
|
||||||
_tcs.TrySetResult(null);
|
|
||||||
RequestClose?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnWindowClosed()
|
|
||||||
{
|
|
||||||
_tcs.TrySetResult(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class TaskItemViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
[ObservableProperty] private string _title;
|
|
||||||
[ObservableProperty] private string _statusText;
|
|
||||||
[ObservableProperty] private string _tagsText;
|
|
||||||
[ObservableProperty] private string _commitType;
|
|
||||||
[ObservableProperty] private string? _description;
|
|
||||||
[ObservableProperty] private TaskStatus _status;
|
|
||||||
[ObservableProperty] private bool _isStarting;
|
|
||||||
[ObservableProperty] private bool _isExpanded;
|
|
||||||
[ObservableProperty] private bool _hasSubtasks;
|
|
||||||
[ObservableProperty] private int _subtaskCount;
|
|
||||||
|
|
||||||
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
|
||||||
|
|
||||||
public string Id { get; }
|
|
||||||
public string ListId { get; }
|
|
||||||
public TaskEntity Entity { get; private set; }
|
|
||||||
|
|
||||||
private readonly Func<string, Task>? _runNow;
|
|
||||||
private readonly Func<bool> _canRunNow;
|
|
||||||
private readonly Func<string, Task>? _toggleDone;
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
||||||
private bool _subtasksLoaded;
|
|
||||||
|
|
||||||
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
|
|
||||||
Func<string, Task>? runNow, Func<bool> canRunNow,
|
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory, int subtaskCount,
|
|
||||||
Func<string, Task>? toggleDone = null)
|
|
||||||
{
|
|
||||||
Entity = entity;
|
|
||||||
Id = entity.Id;
|
|
||||||
ListId = entity.ListId;
|
|
||||||
_title = entity.Title;
|
|
||||||
_status = entity.Status;
|
|
||||||
_statusText = entity.Status.ToString().ToLowerInvariant();
|
|
||||||
_tagsText = string.Join(", ", tags.Select(t => t.Name));
|
|
||||||
_commitType = entity.CommitType;
|
|
||||||
_description = entity.Description;
|
|
||||||
_runNow = runNow;
|
|
||||||
_canRunNow = canRunNow;
|
|
||||||
_toggleDone = toggleDone;
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_subtaskCount = subtaskCount;
|
|
||||||
_hasSubtasks = subtaskCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsDone => Status == TaskStatus.Done;
|
|
||||||
public bool IsRunning => Status == TaskStatus.Running;
|
|
||||||
public bool CanToggleDone => Status != TaskStatus.Running && Status != TaskStatus.Failed;
|
|
||||||
|
|
||||||
public TextDecorationCollection? TitleDecorations => IsDone
|
|
||||||
? TextDecorations.Strikethrough
|
|
||||||
: null;
|
|
||||||
|
|
||||||
public IBrush TitleForeground => IsDone
|
|
||||||
? new SolidColorBrush(Color.Parse("#5a6578"))
|
|
||||||
: new SolidColorBrush(Color.Parse("#e2e8f0"));
|
|
||||||
|
|
||||||
public double RowOpacity => IsDone ? 0.6 : 1.0;
|
|
||||||
|
|
||||||
public void Refresh(TaskEntity entity, IReadOnlyList<TagEntity> tags)
|
|
||||||
{
|
|
||||||
Entity = entity;
|
|
||||||
Title = entity.Title;
|
|
||||||
Status = entity.Status;
|
|
||||||
StatusText = entity.Status.ToString().ToLowerInvariant();
|
|
||||||
TagsText = string.Join(", ", tags.Select(t => t.Name));
|
|
||||||
CommitType = entity.CommitType;
|
|
||||||
Description = entity.Description;
|
|
||||||
RunNowCommand.NotifyCanExecuteChanged();
|
|
||||||
OnPropertyChanged(nameof(IsDone));
|
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
|
||||||
IsStarting = false;
|
|
||||||
OnPropertyChanged(nameof(CanToggleDone));
|
|
||||||
OnPropertyChanged(nameof(TitleDecorations));
|
|
||||||
OnPropertyChanged(nameof(TitleForeground));
|
|
||||||
OnPropertyChanged(nameof(RowOpacity));
|
|
||||||
ToggleDoneCommand.NotifyCanExecuteChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetStarting()
|
|
||||||
{
|
|
||||||
IsStarting = true;
|
|
||||||
StatusText = "starting...";
|
|
||||||
RunNowCommand.NotifyCanExecuteChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClearStarting()
|
|
||||||
{
|
|
||||||
IsStarting = false;
|
|
||||||
RunNowCommand.NotifyCanExecuteChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
|
||||||
private async Task RunNowAsync()
|
|
||||||
{
|
|
||||||
if (_runNow is not null)
|
|
||||||
await _runNow(Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool CanRunNow() =>
|
|
||||||
_canRunNow() && Status != TaskStatus.Running && !IsStarting;
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanToggleDone))]
|
|
||||||
private async Task ToggleDone()
|
|
||||||
{
|
|
||||||
if (_toggleDone is not null)
|
|
||||||
await _toggleDone(Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ToggleExpanded()
|
|
||||||
{
|
|
||||||
IsExpanded = !IsExpanded;
|
|
||||||
if (IsExpanded && !_subtasksLoaded)
|
|
||||||
await LoadSubtasksAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadSubtasksAsync()
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var repo = new SubtaskRepository(context);
|
|
||||||
var entities = await repo.GetByTaskIdAsync(Id);
|
|
||||||
Subtasks.Clear();
|
|
||||||
foreach (var e in entities)
|
|
||||||
Subtasks.Add(SubtaskItemViewModel.From(e));
|
|
||||||
_subtasksLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ToggleSubtaskDone(string subtaskId)
|
|
||||||
{
|
|
||||||
var vm = Subtasks.FirstOrDefault(s => s.Id == subtaskId);
|
|
||||||
if (vm is null) return;
|
|
||||||
vm.Completed = !vm.Completed;
|
|
||||||
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var entity = await context.Subtasks.FindAsync(subtaskId);
|
|
||||||
if (entity is not null)
|
|
||||||
{
|
|
||||||
entity.Completed = vm.Completed;
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RefreshSubtasksAsync(int newCount)
|
|
||||||
{
|
|
||||||
SubtaskCount = newCount;
|
|
||||||
HasSubtasks = newCount > 0;
|
|
||||||
if (!HasSubtasks)
|
|
||||||
{
|
|
||||||
IsExpanded = false;
|
|
||||||
Subtasks.Clear();
|
|
||||||
_subtasksLoaded = false;
|
|
||||||
}
|
|
||||||
else if (_subtasksLoaded || IsExpanded)
|
|
||||||
{
|
|
||||||
await LoadSubtasksAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using ClaudeDo.Ui.Views;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
|
||||||
|
|
||||||
public partial class TaskListViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
||||||
private readonly WorkerClient _worker;
|
|
||||||
private readonly Func<TaskEditorViewModel> _editorFactory;
|
|
||||||
private readonly Action<string> _showMessage;
|
|
||||||
|
|
||||||
public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty] private TaskItemViewModel? _selectedTask;
|
|
||||||
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddTaskCommand))] private string? _currentListId;
|
|
||||||
[ObservableProperty] private string _listName = "Tasks";
|
|
||||||
[ObservableProperty] private string _inlineAddTitle = "";
|
|
||||||
|
|
||||||
public event Action<TaskItemViewModel?>? SelectedTaskChanged;
|
|
||||||
|
|
||||||
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
|
|
||||||
SelectedTaskChanged?.Invoke(value);
|
|
||||||
|
|
||||||
public TaskListViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker,
|
|
||||||
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
|
|
||||||
{
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_worker = worker;
|
|
||||||
_editorFactory = editorFactory;
|
|
||||||
_showMessage = showMessage;
|
|
||||||
|
|
||||||
worker.TaskUpdatedEvent += OnTaskUpdated;
|
|
||||||
worker.TaskFinishedEvent += (_, taskId, _, _) => OnTaskUpdated(taskId);
|
|
||||||
worker.PropertyChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
foreach (var t in Tasks)
|
|
||||||
t.RunNowCommand.NotifyCanExecuteChanged();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.RunNowRequestedEvent += taskId =>
|
|
||||||
{
|
|
||||||
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
|
|
||||||
item?.SetStarting();
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.TaskStartedEvent += (_, taskId, _) =>
|
|
||||||
{
|
|
||||||
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
|
|
||||||
item?.ClearStarting();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LoadAsync(string? listId)
|
|
||||||
{
|
|
||||||
CurrentListId = listId;
|
|
||||||
Tasks.Clear();
|
|
||||||
SelectedTask = null;
|
|
||||||
|
|
||||||
if (listId is not null)
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
var list = await listRepo.GetByIdAsync(listId);
|
|
||||||
ListName = list?.Name ?? "Tasks";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ListName = "Tasks";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listId is null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
var entities = await taskRepo.GetByListIdAsync(listId);
|
|
||||||
var taskIds = entities.Select(e => e.Id).ToList();
|
|
||||||
var subtaskCounts = await context.Subtasks
|
|
||||||
.Where(s => taskIds.Contains(s.TaskId))
|
|
||||||
.GroupBy(s => s.TaskId)
|
|
||||||
.ToDictionaryAsync(g => g.Key, g => g.Count());
|
|
||||||
foreach (var e in entities)
|
|
||||||
{
|
|
||||||
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id);
|
|
||||||
subtaskCounts.TryGetValue(e.Id, out var count);
|
|
||||||
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected,
|
|
||||||
_dbFactory, count, ToggleDoneAsync));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_showMessage($"Error loading tasks: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool CanAddTask() => CurrentListId is not null;
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
|
||||||
private async Task InlineAdd()
|
|
||||||
{
|
|
||||||
var title = InlineAddTitle.Trim();
|
|
||||||
if (string.IsNullOrEmpty(title) || CurrentListId is null) return;
|
|
||||||
|
|
||||||
string defaultCommitType;
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var listRepo = new ListRepository(context);
|
|
||||||
var list = await listRepo.GetByIdAsync(CurrentListId);
|
|
||||||
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
|
||||||
}
|
|
||||||
|
|
||||||
var entity = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = CurrentListId,
|
|
||||||
Title = title,
|
|
||||||
Status = TaskStatus.Manual,
|
|
||||||
CommitType = defaultCommitType,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
await taskRepo.AddAsync(entity);
|
|
||||||
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id);
|
|
||||||
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected,
|
|
||||||
_dbFactory, 0, ToggleDoneAsync);
|
|
||||||
Tasks.Add(vm);
|
|
||||||
SelectedTask = vm;
|
|
||||||
InlineAddTitle = "";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_showMessage($"Error creating task: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
|
|
||||||
var window = new TaskEditorView { DataContext = editor };
|
|
||||||
editor.RequestClose += () => window.Close();
|
|
||||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
|
||||||
_ = ShowDialogAsync(window);
|
|
||||||
|
|
||||||
var saved = await editor.ShowAndWaitAsync();
|
|
||||||
if (saved is null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
var tagRepo = new TagRepository(context);
|
|
||||||
await taskRepo.AddAsync(saved);
|
|
||||||
|
|
||||||
foreach (var tagName in editor.SelectedTagNames)
|
|
||||||
{
|
|
||||||
var tagId = await tagRepo.GetOrCreateAsync(tagName);
|
|
||||||
await taskRepo.AddTagAsync(saved.Id, tagId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
|
|
||||||
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected,
|
|
||||||
_dbFactory, 0, ToggleDoneAsync));
|
|
||||||
|
|
||||||
// Auto wake-queue if agent+queued
|
|
||||||
if (saved.Status == TaskStatus.Queued &&
|
|
||||||
tags.Any(t => t.Name == "agent"))
|
|
||||||
{
|
|
||||||
try { await _worker.WakeQueueAsync(); }
|
|
||||||
catch { /* worker offline is fine */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_showMessage($"Error creating task: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task EditTask()
|
|
||||||
{
|
|
||||||
if (SelectedTask is null || CurrentListId is null) return;
|
|
||||||
|
|
||||||
TaskEntity? entity;
|
|
||||||
List<TagEntity> taskTags;
|
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
|
||||||
{
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
entity = await taskRepo.GetByIdAsync(SelectedTask.Id);
|
|
||||||
if (entity is null) return;
|
|
||||||
taskTags = await taskRepo.GetTagsAsync(entity.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var editor = _editorFactory();
|
|
||||||
await editor.LoadAgentsAsync(_worker);
|
|
||||||
await editor.InitForEditAsync(entity, taskTags);
|
|
||||||
|
|
||||||
var window = new TaskEditorView { DataContext = editor };
|
|
||||||
editor.RequestClose += () => window.Close();
|
|
||||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
|
||||||
_ = ShowDialogAsync(window);
|
|
||||||
|
|
||||||
var saved = await editor.ShowAndWaitAsync();
|
|
||||||
if (saved is null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
var tagRepo = new TagRepository(context);
|
|
||||||
await taskRepo.UpdateAsync(saved);
|
|
||||||
|
|
||||||
var existingTags = await taskRepo.GetTagsAsync(saved.Id);
|
|
||||||
foreach (var old in existingTags)
|
|
||||||
await taskRepo.RemoveTagAsync(saved.Id, old.Id);
|
|
||||||
foreach (var tagName in editor.SelectedTagNames)
|
|
||||||
{
|
|
||||||
var tagId = await tagRepo.GetOrCreateAsync(tagName);
|
|
||||||
await taskRepo.AddTagAsync(saved.Id, tagId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newTags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
|
|
||||||
SelectedTask.Refresh(saved, newTags);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_showMessage($"Error updating task: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task DeleteTask()
|
|
||||||
{
|
|
||||||
if (SelectedTask is null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
await taskRepo.DeleteAsync(SelectedTask.Id);
|
|
||||||
Tasks.Remove(SelectedTask);
|
|
||||||
SelectedTask = null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_showMessage($"Error deleting task: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RefreshSingleAsync(string taskId)
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
var entity = await taskRepo.GetByIdAsync(taskId);
|
|
||||||
var existing = Tasks.FirstOrDefault(t => t.Id == taskId);
|
|
||||||
if (entity is null)
|
|
||||||
{
|
|
||||||
if (existing is not null) Tasks.Remove(existing);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var tags = await taskRepo.GetEffectiveTagsAsync(taskId);
|
|
||||||
if (existing is not null)
|
|
||||||
{
|
|
||||||
existing.Refresh(entity, tags);
|
|
||||||
var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId);
|
|
||||||
await existing.RefreshSubtasksAsync(subtaskCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunNowAsync(string taskId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _worker.RunNowAsync(taskId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_showMessage($"RunNow failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ToggleDoneAsync(string taskId)
|
|
||||||
{
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
|
||||||
var taskRepo = new TaskRepository(context);
|
|
||||||
var entity = await taskRepo.GetByIdAsync(taskId);
|
|
||||||
if (entity is null) return;
|
|
||||||
|
|
||||||
entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done;
|
|
||||||
if (entity.Status == TaskStatus.Done)
|
|
||||||
entity.FinishedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(entity);
|
|
||||||
await RefreshSingleAsync(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ShowDialogAsync(Window dialog)
|
|
||||||
{
|
|
||||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
|
|
||||||
&& desktop.MainWindow is not null)
|
|
||||||
{
|
|
||||||
await dialog.ShowDialog(desktop.MainWindow);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
dialog.Show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
160
src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml
Normal file
160
src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Islands.AgentStripView"
|
||||||
|
x:DataType="vm:DetailsIslandViewModel">
|
||||||
|
<Border Classes="agent-strip"
|
||||||
|
Classes.running="{Binding IsRunning}"
|
||||||
|
Margin="18,8,18,0">
|
||||||
|
<StackPanel Margin="12,10" Spacing="6">
|
||||||
|
|
||||||
|
<!-- Row 1: pulsing dot · status label · model · stop button -->
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||||
|
<Ellipse Grid.Column="0"
|
||||||
|
Width="8" Height="8"
|
||||||
|
Fill="{DynamicResource MossBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Classes.status-pulse="{Binding IsRunning}"
|
||||||
|
Margin="0,0,6,0"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding AgentStatusLabel}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="10"
|
||||||
|
LetterSpacing="1.2"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0"/>
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="{Binding Model}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
IsVisible="{Binding Model, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
<!-- Stop button — only when running -->
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Command="{Binding StopCommand}"
|
||||||
|
IsVisible="{Binding IsRunning}"
|
||||||
|
ToolTip.Tip="Stop agent"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<PathIcon Data="{StaticResource Icon.X}" Width="12" Height="12"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"/>
|
||||||
|
</Button>
|
||||||
|
<!-- Hand off button — only when idle -->
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="btn accent"
|
||||||
|
Content="Hand off"
|
||||||
|
Command="{Binding RunNowCommand}"
|
||||||
|
IsVisible="{Binding !IsRunning}"
|
||||||
|
ToolTip.Tip="Hand task off to Claude"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Padding="10,4"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Row 2: WORKTREE label + path + copy button -->
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
IsVisible="{Binding WorktreePath, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="WORKTREE"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="9"
|
||||||
|
LetterSpacing="1.2"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding WorktreePath}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="icon-btn"
|
||||||
|
ToolTip.Tip="Copy path"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Row 3: Branch line — icon + branch ← main + commits chip -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||||
|
IsVisible="{Binding BranchLine, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<PathIcon Data="{StaticResource Icon.GitBranch}" Width="11" Height="11"
|
||||||
|
Foreground="{DynamicResource AccentBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding BranchLine}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Border Classes="chip"
|
||||||
|
IsVisible="{Binding CommitsOnBranch}"
|
||||||
|
Padding="5,1" CornerRadius="4">
|
||||||
|
<TextBlock Text="{Binding CommitsOnBranch, StringFormat='{}{0}c'}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="9"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Row 4: DIFF label + +add −del + meter bar -->
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,Auto,*">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="DIFF"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="9"
|
||||||
|
LetterSpacing="1.2"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding DiffAdditions, StringFormat='+{0}'}"
|
||||||
|
Classes="diff-add"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,4,0"/>
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="{Binding DiffDeletions, StringFormat='−{0}'}"
|
||||||
|
Classes="diff-del"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0"/>
|
||||||
|
<!-- Slim 4px meter: track + fill using a Grid overlay -->
|
||||||
|
<Grid Grid.Column="3" VerticalAlignment="Center">
|
||||||
|
<Border Classes="diff-meter-track"/>
|
||||||
|
<Rectangle Classes="diff-meter-fill"
|
||||||
|
Width="{Binding DiffMeterRatio}"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
RenderTransformOrigin="0,0.5">
|
||||||
|
<Rectangle.RenderTransform>
|
||||||
|
<ScaleTransform ScaleX="{Binding $parent[Grid].Bounds.Width}"/>
|
||||||
|
</Rectangle.RenderTransform>
|
||||||
|
</Rectangle>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
|
||||||
|
<Button Classes="btn" Content="Open diff" Command="{Binding OpenDiffCommand}"/>
|
||||||
|
<Button Classes="btn" Command="{Binding OpenWorktreeCommand}"
|
||||||
|
ToolTip.Tip="Open worktree in file explorer">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||||
|
<PathIcon Data="{StaticResource Icon.ArrowOut}"
|
||||||
|
Width="11" Height="11"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="Worktree" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Classes="btn accent"
|
||||||
|
Content="Continue"
|
||||||
|
Command="{Binding ContinueCommand}"
|
||||||
|
IsVisible="{Binding ShowFailedActions}"
|
||||||
|
ToolTip.Tip="Resume the task from where it failed"
|
||||||
|
Padding="10,4"/>
|
||||||
|
<Button Classes="btn"
|
||||||
|
Content="Reset"
|
||||||
|
Command="{Binding ResetCommand}"
|
||||||
|
IsVisible="{Binding ShowFailedActions}"
|
||||||
|
ToolTip.Tip="Discard the worktree and move the task back to Manual"
|
||||||
|
Padding="10,4"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
8
src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml.cs
Normal file
8
src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
|
public partial class AgentStripView : UserControl
|
||||||
|
{
|
||||||
|
public AgentStripView() { InitializeComponent(); }
|
||||||
|
}
|
||||||
194
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
Normal file
194
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
|
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
||||||
|
x:DataType="vm:DetailsIslandViewModel">
|
||||||
|
<DockPanel>
|
||||||
|
|
||||||
|
<!-- ── Metadata footer (sticky bottom) ── -->
|
||||||
|
<Border DockPanel.Dock="Bottom"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="14,8">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<Button Grid.Column="0" Classes="icon-btn"
|
||||||
|
Command="{Binding DeleteTaskCommand}"
|
||||||
|
ToolTip.Tip="Delete task"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"/>
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding Task.CreatedAtFormatted}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="2" Classes="icon-btn"
|
||||||
|
Command="{Binding CloseDetailsCommand}"
|
||||||
|
ToolTip.Tip="Close"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<PathIcon Data="{StaticResource Icon.X}" Width="14" Height="14"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── Header (sticky top): eyebrow · title · gear (agent-settings flyout) ── -->
|
||||||
|
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="0">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
||||||
|
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding TaskIdBadge}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||||
|
FontSize="14" FontWeight="Medium"
|
||||||
|
BorderThickness="0" Background="Transparent"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
Padding="0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="1" Classes="icon-btn"
|
||||||
|
ToolTip.Tip="Agent settings"
|
||||||
|
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="6,0,0,0">
|
||||||
|
<TextBlock Text="⚙" FontSize="14"/>
|
||||||
|
<Button.Flyout>
|
||||||
|
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
||||||
|
<StackPanel Width="340" Spacing="10" Margin="4">
|
||||||
|
<TextBlock Text="Agent settings (overrides)" FontWeight="SemiBold"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Model"/>
|
||||||
|
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
||||||
|
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Text="{Binding EffectiveModelHint, StringFormat='Effective if inherited: {0}'}"
|
||||||
|
Opacity="0.6" FontSize="11"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="System prompt (appended)"/>
|
||||||
|
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"
|
||||||
|
PlaceholderText="{Binding EffectiveSystemPromptHint}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Agent file"/>
|
||||||
|
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
|
||||||
|
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Text="{Binding EffectiveAgentHint, StringFormat='Effective if inherited: {0}'}"
|
||||||
|
Opacity="0.6" FontSize="11"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Flyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── Task strip row (sticky top): check + title + star ── -->
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
|
Padding="18,10,18,10"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<Ellipse Grid.Column="0"
|
||||||
|
Classes="task-check"
|
||||||
|
Classes.done="{Binding Task.Done}"
|
||||||
|
Width="18" Height="18"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Cursor="Hand"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding EditableTitle}"
|
||||||
|
FontSize="14" FontWeight="Medium"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="10,0"/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="icon-btn star-btn"
|
||||||
|
Classes.on="{Binding Task.IsStarred}"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── Agent status strip (sticky, above metadata footer) ── -->
|
||||||
|
<islands:AgentStripView DockPanel.Dock="Bottom"/>
|
||||||
|
|
||||||
|
<!-- ── Scrollable body: steps + terminal ── -->
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
|
||||||
|
<!-- Steps section -->
|
||||||
|
<Border Padding="18,12,18,12"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="section-label" Text="STEPS" Margin="0,0,0,2"/>
|
||||||
|
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
|
||||||
|
PlaceholderText="Add a step..."
|
||||||
|
Padding="8"
|
||||||
|
Background="{DynamicResource Surface2Brush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="6">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<ItemsControl ItemsSource="{Binding Subtasks}"
|
||||||
|
IsVisible="{Binding Subtasks.Count}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:SubtaskRowViewModel">
|
||||||
|
<Border Classes="subtask-row"
|
||||||
|
Classes.done="{Binding Done}">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Ellipse Grid.Column="0"
|
||||||
|
Classes="task-check"
|
||||||
|
Classes.done="{Binding Done}"
|
||||||
|
Width="16" Height="16"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Cursor="Hand"
|
||||||
|
Margin="0,0,8,0"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Classes="subtask-title"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
|
||||||
|
<islands:SessionTerminalView MaxHeight="420"/>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
101
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
Normal file
101
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using ClaudeDo.Ui.Views.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
|
public partial class DetailsIslandView : UserControl
|
||||||
|
{
|
||||||
|
public DetailsIslandView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is DetailsIslandViewModel vm)
|
||||||
|
{
|
||||||
|
vm.ShowDiffModal = async (diffVm) =>
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner == null) return;
|
||||||
|
var modal = new DiffModalView { DataContext = diffVm };
|
||||||
|
await modal.ShowDialog(owner);
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.ShowWorktreeModal = async (worktreeVm) =>
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner == null) return;
|
||||||
|
var modal = new WorktreeModalView { DataContext = worktreeVm };
|
||||||
|
await modal.ShowDialog(owner);
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.ShowMergeModal = async (mergeVm) =>
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner == null) return;
|
||||||
|
var modal = new MergeModalView { DataContext = mergeVm };
|
||||||
|
await modal.ShowDialog(owner);
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.ConfirmAsync = ShowConfirmAsync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner == null) return false;
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<bool>();
|
||||||
|
|
||||||
|
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||||
|
var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } };
|
||||||
|
|
||||||
|
var dialog = new Window
|
||||||
|
{
|
||||||
|
Title = "Confirm",
|
||||||
|
Width = 360,
|
||||||
|
SizeToContent = SizeToContent.Height,
|
||||||
|
CanResize = false,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
ShowInTaskbar = false,
|
||||||
|
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||||
|
Content = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 16,
|
||||||
|
Margin = new Thickness(20),
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Spacing = 8,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Children = { cancel, confirm }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||||
|
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||||
|
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||||
|
|
||||||
|
_ = dialog.ShowDialog(owner);
|
||||||
|
return await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotesLostFocus(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is DetailsIslandViewModel vm)
|
||||||
|
vm.SaveNotesCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml
Normal file
192
src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
|
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
|
||||||
|
x:DataType="vm:ListsIslandViewModel">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
||||||
|
<converters:IconKeyConverter x:Key="IconKey"/>
|
||||||
|
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||||
|
<StackPanel Margin="14,12,14,0" Spacing="4">
|
||||||
|
<TextBlock FontFamily="{DynamicResource SansFamily}" FontSize="18"
|
||||||
|
FontWeight="SemiBold" Foreground="{DynamicResource TextBrush}"
|
||||||
|
Text="Lists"/>
|
||||||
|
|
||||||
|
<!-- Search row -->
|
||||||
|
<Border Classes="search-wrap" Margin="0,8,0,12">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||||
|
<PathIcon Grid.Column="0" Width="14" Height="14"
|
||||||
|
Data="{StaticResource Icon.Search}"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
Margin="2,0,0,0"/>
|
||||||
|
<TextBox Grid.Column="1" x:Name="SearchBox" Classes="search-inner"
|
||||||
|
PlaceholderText="Search tasks…"
|
||||||
|
Text="{Binding SearchText, Mode=TwoWay}"/>
|
||||||
|
<Border Grid.Column="2" Classes="kbd" Margin="0,0,2,0">
|
||||||
|
<TextBlock Text="Ctrl K"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── Footer ── -->
|
||||||
|
<Border DockPanel.Dock="Bottom"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0"
|
||||||
|
Padding="12,10">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||||
|
<!-- Avatar circle -->
|
||||||
|
<Border Grid.Column="0" Classes="avatar-circle"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding UserInitials}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource DeepBrush}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<!-- Name + machine -->
|
||||||
|
<StackPanel Grid.Column="1" Margin="8,0" Spacing="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding UserName}"
|
||||||
|
FontSize="12" Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
<TextBlock FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}">
|
||||||
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="{}{0} / local">
|
||||||
|
<Binding Path="MachineName"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- More button -->
|
||||||
|
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
|
||||||
|
Command="{Binding OpenSettingsCommand}"
|
||||||
|
ToolTip.Tip="Settings">
|
||||||
|
<PathIcon Data="{StaticResource Icon.MoreHorizontal}"
|
||||||
|
Width="14" Height="14"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── Scrollable body ── -->
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Margin="6,0,6,4">
|
||||||
|
|
||||||
|
<!-- SMART LISTS section -->
|
||||||
|
<TextBlock Classes="list-section-label" Text="SMART LISTS"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding SmartLists}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:ListNavItemViewModel">
|
||||||
|
<Border Classes="list-item" Classes.active="{Binding IsActive}"
|
||||||
|
Tapped="OnItemTapped">
|
||||||
|
<Grid ColumnDefinitions="20,*,Auto">
|
||||||
|
<!-- Left accent bar for active state -->
|
||||||
|
<Border Grid.Column="0" Grid.ColumnSpan="3"
|
||||||
|
Background="Transparent"
|
||||||
|
CornerRadius="8" IsHitTestVisible="False">
|
||||||
|
<Border.IsVisible>
|
||||||
|
<Binding Path="IsActive"/>
|
||||||
|
</Border.IsVisible>
|
||||||
|
<Border Width="2" Height="16"
|
||||||
|
Background="{DynamicResource AccentBrush}"
|
||||||
|
CornerRadius="1"
|
||||||
|
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
|
Margin="-8,0,0,0"/>
|
||||||
|
</Border>
|
||||||
|
<!-- Icon -->
|
||||||
|
<PathIcon Grid.Column="0" Classes="list-icon"
|
||||||
|
Width="14" Height="14"
|
||||||
|
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Name -->
|
||||||
|
<TextBlock Grid.Column="1" Classes="list-label"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
VerticalAlignment="Center" Margin="8,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
|
||||||
|
<!-- Count -->
|
||||||
|
<TextBlock Grid.Column="2" Classes="list-count"
|
||||||
|
Text="{Binding Count}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- MY LISTS section -->
|
||||||
|
<TextBlock Classes="list-section-label" Text="MY LISTS"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding UserLists}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:ListNavItemViewModel">
|
||||||
|
<Border Classes="list-item" Classes.active="{Binding IsActive}"
|
||||||
|
Tapped="OnItemTapped">
|
||||||
|
<Border.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Settings..."
|
||||||
|
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Border.ContextMenu>
|
||||||
|
<Grid ColumnDefinitions="20,*,Auto,Auto">
|
||||||
|
<!-- Left accent bar for active state -->
|
||||||
|
<Border Grid.Column="0" Grid.ColumnSpan="4"
|
||||||
|
Background="Transparent"
|
||||||
|
CornerRadius="8" IsHitTestVisible="False"
|
||||||
|
IsVisible="{Binding IsActive}">
|
||||||
|
<Border Width="2" Height="16"
|
||||||
|
Background="{DynamicResource AccentBrush}"
|
||||||
|
CornerRadius="1"
|
||||||
|
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
|
Margin="-8,0,0,0"/>
|
||||||
|
</Border>
|
||||||
|
<!-- Color dot (6px circle, color from DotColorKey) -->
|
||||||
|
<Ellipse Grid.Column="0"
|
||||||
|
Width="6" Height="6"
|
||||||
|
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Name -->
|
||||||
|
<TextBlock Grid.Column="1" Classes="list-label"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
VerticalAlignment="Center" Margin="8,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
|
||||||
|
<!-- Count -->
|
||||||
|
<TextBlock Grid.Column="2" Classes="list-count"
|
||||||
|
Text="{Binding Count}"/>
|
||||||
|
<!-- Gear button -->
|
||||||
|
<Button Grid.Column="3" Classes="icon-btn"
|
||||||
|
Content="⚙"
|
||||||
|
FontSize="12"
|
||||||
|
ToolTip.Tip="Settings..."
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- + New list button -->
|
||||||
|
<Button Classes="new-list-btn" Margin="0,4,0,0">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Plus}"
|
||||||
|
Width="13" Height="13"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="New list" FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user