Compare commits
103 Commits
feat/relea
...
23f8fddc4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f8fddc4d | ||
|
|
a180e8446c | ||
|
|
0406d35b61 | ||
|
|
e6b37624a1 | ||
|
|
fca5d57fef | ||
|
|
cfb9ca1ca4 | ||
|
|
62a1121571 | ||
|
|
4283c67d81 | ||
|
|
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 | ||
|
|
2a8cd97d02 | ||
|
|
09e8b1f10b | ||
|
|
92d8d902df | ||
|
|
aa1008dcff | ||
|
|
5f3d41e1f6 | ||
|
|
7d48f34b15 | ||
|
|
51a1bbe6b8 | ||
|
|
ad7c9facaf | ||
|
|
11a4376da5 | ||
|
|
f10ad69863 | ||
|
|
dc4571a338 | ||
|
|
4fb6ba6be8 | ||
|
|
3423919655 | ||
|
|
fca2bdb596 | ||
|
|
721f0cd903 | ||
|
|
32bb52875f | ||
|
|
4f25c3dd40 | ||
| 33fedc7e26 | |||
|
|
4ca48044db | ||
|
|
611454df1e | ||
|
|
8d61b05179 | ||
|
|
7d0ca45a60 | ||
|
|
36484ed45a | ||
|
|
b7be52a623 | ||
|
|
34ca1b018f | ||
|
|
51a5dcbb73 | ||
|
|
f8f13865d2 | ||
|
|
a064865417 | ||
|
|
9236ca6d45 | ||
|
|
9e1f1370bb | ||
|
|
3b1f148122 | ||
|
|
2b3fe02d8c | ||
|
|
d3b85f2234 | ||
|
|
fc9029de97 | ||
|
|
1c764dae3f | ||
|
|
cfec3297a4 | ||
|
|
6e1d64b489 | ||
|
|
f599f8d0af | ||
|
|
9b928c6217 | ||
| c9e38aef88 | |||
| 66843d242b | |||
| 6afe5959ca | |||
|
|
9a407bde83 | ||
|
|
8c051d8f62 | ||
|
|
8577c55685 |
@@ -67,7 +67,7 @@ jobs:
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
/p:Version=$VERSION -o out/worker
|
||||
|
||||
- name: Publish ClaudeDo.Installer (win-x64, single-file)
|
||||
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
@@ -75,8 +75,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
cd "$WORK/src"
|
||||
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
|
||||
# the previous self-contained bundle crashed at startup (apphost AV).
|
||||
# Target machines need .NET 8 Desktop Runtime (x64).
|
||||
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
||||
-c Release -r win-x64 --self-contained true \
|
||||
-c Release -r win-x64 --self-contained false \
|
||||
/p:Version=$VERSION /p:PublishSingleFile=true \
|
||||
-o out/installer
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ Desktop.ini
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
design-time.db
|
||||
|
||||
@@ -15,7 +15,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
## Tech Stack
|
||||
|
||||
- .NET 8.0, 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 (Microsoft.EntityFrameworkCore.Sqlite)
|
||||
- SignalR for real-time IPC
|
||||
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
|
||||
- Git worktrees for task isolation
|
||||
@@ -27,12 +27,14 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- Worker config: `~/.todo-app/worker.config.json`
|
||||
- Logs: `~/.todo-app/logs/`
|
||||
- Worktrees: configured per worker (sibling or central strategy)
|
||||
- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
|
||||
|
||||
## Conventions
|
||||
|
||||
- Repository pattern — each entity has its own async repository
|
||||
- All data operations are async with CancellationToken support
|
||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Manual | Queued -> Running -> Done | Failed
|
||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
|
||||
|
||||
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
1722
docs/superpowers/plans/2026-04-16-efcore-migration.md
Normal file
1722
docs/superpowers/plans/2026-04-16-efcore-migration.md
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
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).
|
||||
253
docs/superpowers/specs/2026-04-16-efcore-migration-design.md
Normal file
253
docs/superpowers/specs/2026-04-16-efcore-migration-design.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# EF Core Migration Design
|
||||
|
||||
Replace the raw ADO.NET / Microsoft.Data.Sqlite data layer with Entity Framework Core and LINQ queries.
|
||||
|
||||
## Motivation
|
||||
|
||||
- Developer ergonomics: raw SQL is tedious to write and maintain; LINQ enables faster iteration.
|
||||
- Maintainability: the ad-hoc migration approach (ALTER TABLE with error-code catching) and manual DBNull/enum mapping are a liability as the schema grows. EF Core provides proper migration versioning, value converters, and change tracking.
|
||||
|
||||
## Decision Summary
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Approach | Big bang — rewrite all 6 repositories at once |
|
||||
| Migration strategy | Fresh start — EF Core owns the schema, drop schema.sql |
|
||||
| DbContext sharing | Single shared `ClaudeDoDbContext` in ClaudeDo.Data |
|
||||
| Configuration style | Fluent API only, clean POCO models |
|
||||
| Atomic queue claim | Kept as `FromSqlRaw` — not expressible in LINQ |
|
||||
|
||||
---
|
||||
|
||||
## 1. DbContext and Entity Configuration
|
||||
|
||||
### ClaudeDoDbContext
|
||||
|
||||
A single `ClaudeDoDbContext` in `ClaudeDo.Data` with DbSets for all entities:
|
||||
|
||||
```csharp
|
||||
public class ClaudeDoDbContext : DbContext
|
||||
{
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<TagEntity> Tags => Set<TagEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||
}
|
||||
```
|
||||
|
||||
### Entity-to-Table Mapping
|
||||
|
||||
| Entity | Table | Key | Notes |
|
||||
|---|---|---|---|
|
||||
| `TaskEntity` | `tasks` | `Id` (TEXT) | Nav to List, Tags, Worktree, Runs, Subtasks |
|
||||
| `ListEntity` | `lists` | `Id` (TEXT) | Nav to Tasks, Tags, Config |
|
||||
| `TagEntity` | `tags` | `Id` (INTEGER auto) | Nav to Lists, Tasks (both M:N) |
|
||||
| `ListConfigEntity` | `list_config` | `ListId` (TEXT) | 1:1 owned by List |
|
||||
| `WorktreeEntity` | `worktrees` | `TaskId` (TEXT) | 1:1 owned by Task |
|
||||
| `TaskRunEntity` | `task_runs` | `Id` (TEXT) | FK to Task |
|
||||
| `SubtaskEntity` | `subtasks` | `Id` (TEXT) | FK to Task |
|
||||
|
||||
### Navigation Properties Added to Models
|
||||
|
||||
```csharp
|
||||
// TaskEntity gains:
|
||||
public ListEntity List { get; set; }
|
||||
public WorktreeEntity? Worktree { get; set; }
|
||||
public ICollection<TagEntity> Tags { get; set; }
|
||||
public ICollection<TaskRunEntity> Runs { get; set; }
|
||||
public ICollection<SubtaskEntity> Subtasks { get; set; }
|
||||
|
||||
// ListEntity gains:
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
public ICollection<TaskEntity> Tasks { get; set; }
|
||||
public ICollection<TagEntity> Tags { get; set; }
|
||||
|
||||
// TagEntity gains:
|
||||
public ICollection<ListEntity> Lists { get; set; }
|
||||
public ICollection<TaskEntity> Tasks { get; set; }
|
||||
```
|
||||
|
||||
### Enum Handling
|
||||
|
||||
EF Core `ValueConverter<TEnum, string>` for `TaskStatus` and `WorktreeState`, storing the same lowercase strings (`"manual"`, `"active"`, etc.) for database compatibility. The `ToDb`/`FromDb` methods in repositories are removed.
|
||||
|
||||
### Junction Tables
|
||||
|
||||
`list_tags` and `task_tags` are configured as implicit join tables via `.UsingEntity()` in Fluent API — no explicit junction entity classes needed.
|
||||
|
||||
### Fluent Configuration
|
||||
|
||||
Each entity gets its own `IEntityTypeConfiguration<T>` class in a `Configuration/` folder within `ClaudeDo.Data`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration Strategy
|
||||
|
||||
### Fresh Start
|
||||
|
||||
- `schema.sql` and `SchemaInitializer` are deleted.
|
||||
- An initial EF Core migration (`InitialCreate`) is generated from the DbContext model, producing the full schema (all 8 tables, indexes, foreign keys, check constraints).
|
||||
- EF's `__EFMigrationsHistory` table tracks applied migrations.
|
||||
|
||||
### Startup
|
||||
|
||||
Both App and Worker call `context.Database.Migrate()` at startup instead of `SchemaInitializer.Apply()`. This is idempotent.
|
||||
|
||||
### Existing Database Compatibility
|
||||
|
||||
For users who already have a database created by `schema.sql`, the initial migration must handle the schema already existing. On startup, if the `lists` table exists but `__EFMigrationsHistory` does not, insert the initial migration record into `__EFMigrationsHistory` so EF skips it.
|
||||
|
||||
### Seed Data
|
||||
|
||||
The `"agent"` and `"manual"` tags move into `OnModelCreating` via `HasData()`:
|
||||
|
||||
```csharp
|
||||
modelBuilder.Entity<TagEntity>().HasData(
|
||||
new TagEntity { Id = 1, Name = "agent" },
|
||||
new TagEntity { Id = 2, Name = "manual" });
|
||||
```
|
||||
|
||||
### Ad-hoc Migrations Removed
|
||||
|
||||
The 3 manual `ALTER TABLE` statements (model, system_prompt, agent_path on tasks) become part of the initial migration since they're already in the model. The manual `ApplyMigrations()` method is deleted.
|
||||
|
||||
---
|
||||
|
||||
## 3. Repository Rewrite
|
||||
|
||||
All 6 repositories are rewritten to use `ClaudeDoDbContext` and LINQ.
|
||||
|
||||
### Per-Repository Changes
|
||||
|
||||
| Repository | After EF Core |
|
||||
|---|---|
|
||||
| `TagRepository` | LINQ queries. `GetOrCreateAsync` uses `FirstOrDefaultAsync` + `Add` + `SaveChangesAsync`. Static `SqliteConnection` overload removed. |
|
||||
| `SubtaskRepository` | Straightforward LINQ CRUD, `.OrderBy(s => s.OrderNum)`. |
|
||||
| `WorktreeRepository` | LINQ CRUD. State update becomes property set + `SaveChangesAsync`. |
|
||||
| `ListRepository` | LINQ CRUD. Tag management via `.Tags` navigation property. Config upsert via `List.Config` navigation. |
|
||||
| `TaskRunRepository` | LINQ CRUD. Latest = `.OrderByDescending(r => r.RunNumber).FirstOrDefaultAsync()`. |
|
||||
| `TaskRepository` | See special cases below. |
|
||||
|
||||
### TaskRepository Special Cases
|
||||
|
||||
**Atomic queue claim** (`GetNextQueuedAgentTaskAsync`): kept as `FromSqlRaw` / `ExecuteSqlRawAsync`. The `UPDATE ... WHERE id = (SELECT ...) RETURNING` is not expressible in LINQ and the atomicity guarantee matters.
|
||||
|
||||
**Effective tags** (`GetEffectiveTagsAsync`): LINQ via navigation properties:
|
||||
|
||||
```csharp
|
||||
var taskTags = context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.Tags);
|
||||
var listTags = context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.List.Tags);
|
||||
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
|
||||
```
|
||||
|
||||
**FlipAllRunningToFailed**: EF Core 7+ bulk update:
|
||||
|
||||
```csharp
|
||||
await context.Tasks
|
||||
.Where(t => t.Status == TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Failed), ct);
|
||||
```
|
||||
|
||||
**Status transitions** (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`): property updates + `SaveChangesAsync`.
|
||||
|
||||
### Removed Code
|
||||
|
||||
- `SqliteConnectionFactory.cs`
|
||||
- `SchemaInitializer.cs`
|
||||
- `schema/schema.sql`
|
||||
- All `ToDb`/`FromDb` enum mapping methods
|
||||
- All manual `DBNull.Value` handling
|
||||
- `BindTask` helper methods
|
||||
|
||||
---
|
||||
|
||||
## 4. Package Changes and DI Registration
|
||||
|
||||
### ClaudeDo.Data.csproj
|
||||
|
||||
- Remove: `Microsoft.Data.Sqlite`
|
||||
- Remove: embedded resource for `schema.sql`
|
||||
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
|
||||
- Add: `Microsoft.EntityFrameworkCore.Design` (`PrivateAssets="all"`)
|
||||
|
||||
### ClaudeDo.Worker.Tests.csproj
|
||||
|
||||
- Remove: `Microsoft.Data.Sqlite`
|
||||
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
|
||||
|
||||
### App DI (Program.cs)
|
||||
|
||||
```csharp
|
||||
// Replace SqliteConnectionFactory + singleton repos with:
|
||||
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||
opt.UseSqlite($"Data Source={dbPath}"));
|
||||
sc.AddScoped<ClaudeDoDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
sc.AddScoped<ListRepository>();
|
||||
sc.AddScoped<TaskRepository>();
|
||||
sc.AddScoped<SubtaskRepository>();
|
||||
sc.AddScoped<TagRepository>();
|
||||
sc.AddScoped<WorktreeRepository>();
|
||||
sc.AddScoped<TaskRunRepository>();
|
||||
|
||||
// Migrate at startup:
|
||||
using var initScope = services.CreateScope();
|
||||
initScope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
|
||||
```
|
||||
|
||||
ViewModels are singletons that currently take repositories as constructor parameters. Since repositories become scoped, ViewModels switch to taking `IDbContextFactory<ClaudeDoDbContext>` and create a fresh context (+ repositories) per operation. Each ViewModel method that touches data does: `using var context = _factory.CreateDbContext();` then constructs or resolves the needed repository with that context. This mirrors the current connection-per-call pattern.
|
||||
|
||||
### Worker DI (Program.cs)
|
||||
|
||||
```csharp
|
||||
builder.Services.AddDbContext<ClaudeDoDbContext>(opt =>
|
||||
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
||||
builder.Services.AddScoped<ListRepository>();
|
||||
builder.Services.AddScoped<TaskRepository>();
|
||||
builder.Services.AddScoped<SubtaskRepository>();
|
||||
builder.Services.AddScoped<TagRepository>();
|
||||
builder.Services.AddScoped<WorktreeRepository>();
|
||||
builder.Services.AddScoped<TaskRunRepository>();
|
||||
|
||||
// Migrate at startup after build:
|
||||
using var scope = app.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
|
||||
```
|
||||
|
||||
Worker has request scopes via SignalR hub invocations, so scoped registration works naturally.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Infrastructure
|
||||
|
||||
### DbFixture
|
||||
|
||||
`DbFixture` is rewritten as an EF Core fixture:
|
||||
|
||||
- Creates a temp SQLite file per test class.
|
||||
- Builds `DbContextOptions<ClaudeDoDbContext>` with `UseSqlite`.
|
||||
- Calls `context.Database.Migrate()` to apply the schema (also tests that migrations work).
|
||||
- Exposes a `CreateContext()` method so each test gets a fresh context instance (avoids change-tracker bleed).
|
||||
|
||||
Tests construct repositories by passing in a fresh context from the fixture.
|
||||
|
||||
No mocking — tests keep hitting real SQLite, same philosophy as today.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk and Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Big-bang rewrite touches nearly every file in ClaudeDo.Data | Existing tests are the safety net — all must pass after migration |
|
||||
| Existing databases with schema from schema.sql | Compatibility shim: detect existing tables, mark initial migration as applied |
|
||||
| Atomic queue claim semantics change | Kept as raw SQL via `FromSqlRaw` |
|
||||
| Scoped lifetime vs. singleton ViewModels | `IDbContextFactory` provides on-demand contexts |
|
||||
| EF change tracker overhead vs. raw ADO.NET | Negligible for this workload size; use `AsNoTracking()` for read-only queries |
|
||||
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal file
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Subtask Tree View in Task List
|
||||
|
||||
## Problem
|
||||
|
||||
Subtasks are invisible in the task list — users only see them after opening the detail pane or editor modal. This makes it hard to get an overview of task progress without clicking into each task individually.
|
||||
|
||||
## Solution
|
||||
|
||||
Show subtasks indented below their parent task in the task list, with expand/collapse. Tasks start collapsed with a visual indicator when subtasks exist.
|
||||
|
||||
## Scope
|
||||
|
||||
Pure UI/ViewModel change. No data model changes, no new migrations, no repository schema changes.
|
||||
|
||||
## Design
|
||||
|
||||
### ViewModel Changes
|
||||
|
||||
**TaskItemViewModel** — add:
|
||||
|
||||
- `ObservableCollection<SubtaskItemViewModel> Subtasks` — populated on first expand
|
||||
- `bool IsExpanded` — observable, default `false`; toggles subtask visibility
|
||||
- `bool HasSubtasks` — observable, set during initial load from a count query
|
||||
- `int SubtaskCount` — observable, used for the indicator
|
||||
- `ToggleExpandedCommand` — flips `IsExpanded`; on first expand, loads subtasks from `SubtaskRepository.GetByTaskIdAsync`
|
||||
- `ToggleSubtaskDoneCommand(string subtaskId)` — toggles a subtask's `Completed` and persists via `SubtaskRepository.UpdateAsync`
|
||||
|
||||
Constructor gains `SubtaskRepository` and initial `subtaskCount` parameter.
|
||||
|
||||
**TaskListViewModel.LoadAsync** — after fetching tasks, run a single batch query to get subtask counts per task. Pass counts into each `TaskItemViewModel`. This avoids N+1 queries on load.
|
||||
|
||||
**TaskListViewModel.RefreshSingleAsync** — if the refreshed task's `IsExpanded` is true, also reload its subtasks from DB and update the collection.
|
||||
|
||||
### Repository Changes
|
||||
|
||||
**SubtaskRepository** — add one method:
|
||||
|
||||
```csharp
|
||||
Task<Dictionary<string, int>> GetCountsByTaskIdsAsync(IEnumerable<string> taskIds, CancellationToken ct = default)
|
||||
```
|
||||
|
||||
Single query: `SELECT task_id, COUNT(*) FROM subtasks WHERE task_id IN (...) GROUP BY task_id`. Returns a map of taskId -> count. Tasks with no subtasks won't appear in the result (count defaults to 0).
|
||||
|
||||
### XAML Changes
|
||||
|
||||
**TaskListView.axaml** — the `DataTemplate` for `TaskItemViewModel` becomes a 2-row grid:
|
||||
|
||||
```
|
||||
Row 0: [ExpandChevron] [StatusCircle] [Title + Tags/Status subtitle]
|
||||
Row 1: [SubtaskItemsControl, margin-left ~40px, visible when IsExpanded]
|
||||
```
|
||||
|
||||
**Row 0 — Expand chevron:**
|
||||
- Column 0 gets a small chevron button (12x12 `Path` data) before the status circle
|
||||
- Right-pointing when collapsed, down-pointing when expanded
|
||||
- Bound to `ToggleExpandedCommand`
|
||||
- Only visible when `HasSubtasks` is true (via `IsVisible` binding)
|
||||
- When `HasSubtasks` is false, the space is empty but reserved (fixed-width column) so all titles align
|
||||
|
||||
**Row 1 — Subtask list:**
|
||||
- `ItemsControl` bound to `Subtasks`
|
||||
- `IsVisible` bound to `IsExpanded`
|
||||
- Left margin ~40px for visual indentation
|
||||
- Each subtask item: `CheckBox` (bound to `Completed`) + `TextBlock` (bound to `Title`)
|
||||
- Subtask row has its own context menu flyout with "Edit Task" (opens parent task's editor modal via `EditTaskCommand` on root `TaskListViewModel`)
|
||||
- Checkbox toggle calls `ToggleSubtaskDoneCommand` on the parent `TaskItemViewModel`
|
||||
|
||||
**Column layout change:** The existing 2-column `Grid` (`Auto, *`) gets a third column prepended: `Auto, Auto, *`. The chevron goes in column 0, status circle in column 1, title stack in column 2. Row 1 spans all 3 columns.
|
||||
|
||||
### Subtask Checkbox Interaction
|
||||
|
||||
When a subtask checkbox is toggled in the list:
|
||||
1. Update the `SubtaskItemViewModel.Completed` property
|
||||
2. Call `SubtaskRepository.UpdateAsync` with the updated entity (same auto-save pattern as `TaskDetailView`)
|
||||
3. No need to refresh the parent task — subtask completion doesn't affect task status
|
||||
|
||||
### Subtask Context Menu
|
||||
|
||||
Right-click on a subtask row shows:
|
||||
- "Edit Task" — opens the parent task's editor modal (same flow as `EditTaskCommand`)
|
||||
|
||||
This reuses the existing editor which already has full subtask editing (add/remove/reorder/rename).
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
When `RefreshSingleAsync` fires (via SignalR `TaskUpdatedEvent`):
|
||||
1. Reload subtask count, update `HasSubtasks` and `SubtaskCount`
|
||||
2. If `IsExpanded`, reload subtask list from DB and reconcile with the observable collection
|
||||
|
||||
### Detail Pane Sync
|
||||
|
||||
When the user edits subtasks in `TaskDetailView` (auto-save) or `TaskEditorView` (batch-save), the list view's subtask state may become stale. Two options:
|
||||
|
||||
**Chosen approach:** The detail pane and editor already trigger `TaskUpdatedEvent` (or the editor's save path calls `RefreshSingleAsync` via `SelectedTask.Refresh`). Extend `Refresh` on `TaskItemViewModel` to also reload subtasks if expanded, and update `HasSubtasks`/`SubtaskCount`.
|
||||
|
||||
### Visual Style
|
||||
|
||||
- Chevron: 10x10 path, `TextDimBrush` color, no background, cursor=Hand
|
||||
- Subtask rows: smaller font (12px), `TextDimBrush` for unchecked title, strikethrough + dimmed for completed
|
||||
- Subtask checkbox: standard Avalonia `CheckBox` (no custom circular border), small size
|
||||
- Subtask row vertical padding: 2px (compact)
|
||||
- Indent: 40px left margin on the subtask `ItemsControl`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — add `GetCountsByTaskIdsAsync`
|
||||
2. `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — add subtask collection, expand/collapse, toggle done
|
||||
3. `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — batch-load counts, pass SubtaskRepository, extend refresh
|
||||
4. `src/ClaudeDo.Ui/Views/TaskListView.axaml` — restructure item template with chevron + nested ItemsControl
|
||||
5. `src/ClaudeDo.Ui/Views/TaskListView.axaml.cs` — handle subtask context menu pointer-pressed if needed
|
||||
6. `src/ClaudeDo.App/Program.cs` — pass SubtaskRepository to TaskListViewModel (if not already available via DI)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Drag-to-reorder subtasks in the list view
|
||||
- Add subtask directly from the list view
|
||||
- Subtask progress indicator (e.g., "2/5 done") on collapsed tasks
|
||||
- Recursive task nesting (tasks containing tasks)
|
||||
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.
|
||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.418",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
-- ClaudeDo SQLite schema (single source of truth, 3NF)
|
||||
-- Applied by Worker on first startup. WAL mode is set via PRAGMA after open.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
working_dir TEXT NULL,
|
||||
default_commit_type TEXT NOT NULL DEFAULT 'chore'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('manual','queued','running','done','failed')),
|
||||
scheduled_for TIMESTAMP NULL,
|
||||
result TEXT NULL,
|
||||
log_path TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
commit_type TEXT NOT NULL DEFAULT 'chore'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_list_id ON tasks(list_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_tags (
|
||||
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (list_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_tags (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_config (
|
||||
list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
|
||||
model TEXT NULL,
|
||||
system_prompt TEXT NULL,
|
||||
agent_path TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worktrees (
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
base_commit TEXT NOT NULL,
|
||||
head_commit TEXT NULL,
|
||||
diff_stat TEXT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','discarded','kept')),
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
run_number INTEGER NOT NULL,
|
||||
session_id TEXT NULL,
|
||||
is_retry INTEGER NOT NULL DEFAULT 0,
|
||||
prompt TEXT NOT NULL,
|
||||
result_markdown TEXT NULL,
|
||||
structured_output TEXT NULL,
|
||||
error_markdown TEXT NULL,
|
||||
exit_code INTEGER NULL,
|
||||
turn_count INTEGER NULL,
|
||||
tokens_in INTEGER NULL,
|
||||
tokens_out INTEGER NULL,
|
||||
log_path TEXT NULL,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
|
||||
|
||||
-- Seed: minimal tag set (ignored if already present)
|
||||
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
||||
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
|
||||
@@ -2,33 +2,24 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="ClaudeDo.App.App"
|
||||
xmlns:local="using:ClaudeDo.App"
|
||||
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.Resources>
|
||||
<!-- Accent: Forest Teal -->
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#3d9474"/>
|
||||
<SolidColorBrush x:Key="AccentLightBrush" Color="#6bb89e"/>
|
||||
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#1A3D9474"/>
|
||||
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#263D9474"/>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://ClaudeDo.Ui/Design/Tokens.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#f1f5f9"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#c8d0dc"/>
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#8892a2"/>
|
||||
<SolidColorBrush x:Key="TextDimBrush" Color="#6b7688"/>
|
||||
<!-- Converters -->
|
||||
<converters:NotNullToBoolConverter x:Key="NotNullToBool"/>
|
||||
<converters:StrikeIfTrueConverter x:Key="StrikeIfTrue"/>
|
||||
<converters:EqStatusConverter x:Key="EqStatus"/>
|
||||
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
||||
<converters:IconKeyConverter x:Key="IconKey"/>
|
||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||
|
||||
<!-- Borders & Backgrounds -->
|
||||
<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"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.DataTemplates>
|
||||
@@ -37,14 +28,15 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#333d9474"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#1A3D9474"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#403D9474"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/>
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -22,8 +23,12 @@ public partial class App : Application
|
||||
{
|
||||
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();
|
||||
|
||||
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 B |
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\ClaudeTask.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
using Avalonia;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ClaudeDo.App;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SetCurrentProcessExplicitAppUserModelID(string appId);
|
||||
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
|
||||
|
||||
var services = BuildServices();
|
||||
App.Services = services;
|
||||
|
||||
// Ensure DB schema exists
|
||||
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
||||
SchemaInitializer.Apply(factory);
|
||||
using (var scope = services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>();
|
||||
ClaudeDoDbContext.MigrateAndConfigure(db);
|
||||
}
|
||||
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Dispose the container so WorkerClient.DisposeAsync runs —
|
||||
// cancels the retry loop and closes the SignalR connection cleanly
|
||||
// instead of abandoning it.
|
||||
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
||||
catch { /* best effort on shutdown */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
@@ -44,51 +66,32 @@ sealed class Program
|
||||
|
||||
// Infrastructure
|
||||
sc.AddSingleton(settings);
|
||||
sc.AddSingleton(new SqliteConnectionFactory(dbPath));
|
||||
|
||||
// Repositories
|
||||
sc.AddSingleton<ListRepository>();
|
||||
sc.AddSingleton<TaskRepository>();
|
||||
sc.AddSingleton<TagRepository>();
|
||||
sc.AddSingleton<WorktreeRepository>();
|
||||
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||
opt.UseSqlite($"Data Source={dbPath}"));
|
||||
sc.AddScoped<ClaudeDoDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
|
||||
// Services
|
||||
sc.AddSingleton<GitService>();
|
||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||
|
||||
// ViewModels
|
||||
sc.AddTransient<ListEditorViewModel>();
|
||||
sc.AddTransient<TaskEditorViewModel>();
|
||||
sc.AddSingleton<StatusBarViewModel>();
|
||||
sc.AddSingleton<TaskDetailViewModel>(sp => new TaskDetailViewModel(
|
||||
sp.GetRequiredService<TaskRepository>(),
|
||||
sp.GetRequiredService<WorktreeRepository>(),
|
||||
sp.GetRequiredService<ListRepository>(),
|
||||
sp.GetRequiredService<GitService>(),
|
||||
sp.GetRequiredService<WorkerClient>(),
|
||||
sp.GetRequiredService<TagRepository>()));
|
||||
sc.AddSingleton<TaskListViewModel>(sp =>
|
||||
{
|
||||
var taskRepo = sp.GetRequiredService<TaskRepository>();
|
||||
var tagRepo = sp.GetRequiredService<TagRepository>();
|
||||
var listRepo = sp.GetRequiredService<ListRepository>();
|
||||
var worker = sp.GetRequiredService<WorkerClient>();
|
||||
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
|
||||
return new TaskListViewModel(
|
||||
taskRepo, tagRepo, listRepo, worker,
|
||||
() => sp.GetRequiredService<TaskEditorViewModel>(),
|
||||
msg => statusBar.ShowMessage(msg));
|
||||
});
|
||||
sc.AddSingleton<MainWindowViewModel>(sp =>
|
||||
{
|
||||
return new MainWindowViewModel(
|
||||
sp.GetRequiredService<ListRepository>(),
|
||||
sc.AddTransient<WorktreeModalViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
new ListsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp));
|
||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
|
||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||
new DetailsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>(),
|
||||
sp.GetRequiredService<TaskListViewModel>(),
|
||||
sp.GetRequiredService<TaskDetailViewModel>(),
|
||||
sp.GetRequiredService<StatusBarViewModel>(),
|
||||
() => sp.GetRequiredService<ListEditorViewModel>());
|
||||
});
|
||||
sp));
|
||||
sc.AddSingleton<IslandsShellViewModel>();
|
||||
|
||||
return sc.BuildServiceProvider();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## Repositories
|
||||
|
||||
All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work.
|
||||
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`
|
||||
- **ListRepository** — CRUD, tag junction management
|
||||
@@ -20,8 +20,8 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA
|
||||
- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE)
|
||||
- **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
|
||||
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
|
||||
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
|
||||
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
|
||||
|
||||
@@ -31,11 +31,11 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
|
||||
|
||||
## Schema
|
||||
|
||||
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual".
|
||||
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".
|
||||
|
||||
## Conventions
|
||||
|
||||
- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum
|
||||
- Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration<T>`)
|
||||
- Entity configurations live in the `Configuration/` folder
|
||||
- Primary keys are `init`-only strings (GUIDs assigned at creation)
|
||||
- Nullable fields use `DBNull.Value` checks
|
||||
- All methods are async with CancellationToken where applicable
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\..\schema\schema.sql" Link="schema.sql" LogicalName="ClaudeDo.Data.schema.sql" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
80
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal file
80
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Seeding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public class ClaudeDoDbContext : DbContext
|
||||
{
|
||||
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<TagEntity> Tags => Set<TagEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies EF Core migrations and sets WAL mode. Safe for both fresh and existing databases.
|
||||
/// Existing databases (created by the old schema.sql) have their tables but no
|
||||
/// __EFMigrationsHistory — this method detects that case and baselines the initial
|
||||
/// migration so EF skips re-creating tables that already exist.
|
||||
/// </summary>
|
||||
public static void MigrateAndConfigure(ClaudeDoDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
try
|
||||
{
|
||||
conn.Open();
|
||||
|
||||
// Set WAL FIRST, before migrations — prevents write-lock contention
|
||||
// when UI and Worker start simultaneously.
|
||||
using (var walCmd = conn.CreateCommand())
|
||||
{
|
||||
walCmd.CommandText = "PRAGMA journal_mode=wal;";
|
||||
walCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
||||
// this is a pre-EF database. Baseline the InitialCreate migration.
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
|
||||
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
|
||||
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
|
||||
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
|
||||
|
||||
if (hasLists && !hasHistory)
|
||||
{
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE "__EFMigrationsHistory" (
|
||||
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||
"ProductVersion" TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ('20260416064948_InitialCreate', '8.0.11');
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
conn.Close();
|
||||
}
|
||||
|
||||
db.Database.Migrate();
|
||||
DefaultListsSeeder.SeedAsync(db).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal file
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public sealed class ClaudeDoDbContextFactory : IDesignTimeDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
public ClaudeDoDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite("Data Source=design-time.db")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfigEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ListConfigEntity> builder)
|
||||
{
|
||||
builder.ToTable("list_config");
|
||||
|
||||
builder.HasKey(c => c.ListId);
|
||||
builder.Property(c => c.ListId).HasColumnName("list_id");
|
||||
builder.Property(c => c.Model).HasColumnName("model");
|
||||
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
|
||||
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
||||
}
|
||||
}
|
||||
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal file
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ListEntity> builder)
|
||||
{
|
||||
builder.ToTable("lists");
|
||||
|
||||
builder.HasKey(l => l.Id);
|
||||
builder.Property(l => l.Id).HasColumnName("id");
|
||||
builder.Property(l => l.Name).HasColumnName("name").IsRequired();
|
||||
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
|
||||
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
||||
|
||||
builder.HasOne(l => l.Config)
|
||||
.WithOne(c => c.List)
|
||||
.HasForeignKey<ListConfigEntity>(c => c.ListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(l => l.Tags)
|
||||
.WithMany(tag => tag.Lists)
|
||||
.UsingEntity("list_tags",
|
||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
||||
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
|
||||
j =>
|
||||
{
|
||||
j.HasKey("list_id", "tag_id");
|
||||
j.ToTable("list_tags");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class SubtaskEntityConfiguration : IEntityTypeConfiguration<SubtaskEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SubtaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("subtasks");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id");
|
||||
builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired();
|
||||
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
|
||||
builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired();
|
||||
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Task)
|
||||
.WithMany(t => t.Subtasks)
|
||||
.HasForeignKey(s => s.TaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id");
|
||||
}
|
||||
}
|
||||
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal file
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TagEntity> builder)
|
||||
{
|
||||
builder.ToTable("tags");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
|
||||
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
|
||||
builder.HasData(
|
||||
new TagEntity { Id = 1, Name = "agent" },
|
||||
new TagEntity { Id = 2, Name = "manual" });
|
||||
}
|
||||
}
|
||||
78
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal file
78
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
{
|
||||
private static string StatusToString(TaskStatus v)
|
||||
=> v == TaskStatus.Manual ? "manual"
|
||||
: v == TaskStatus.Queued ? "queued"
|
||||
: v == TaskStatus.Running ? "running"
|
||||
: v == TaskStatus.Done ? "done"
|
||||
: v == TaskStatus.Failed ? "failed"
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static TaskStatus StatusFromString(string v)
|
||||
=> v == "manual" ? TaskStatus.Manual
|
||||
: v == "queued" ? TaskStatus.Queued
|
||||
: v == "running" ? TaskStatus.Running
|
||||
: v == "done" ? TaskStatus.Done
|
||||
: v == "failed" ? TaskStatus.Failed
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||
new(v => StatusToString(v), v => StatusFromString(v));
|
||||
|
||||
public void Configure(EntityTypeBuilder<TaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("tasks");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id");
|
||||
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
|
||||
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
|
||||
builder.Property(t => t.Description).HasColumnName("description");
|
||||
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
|
||||
.HasConversion(StatusConverter);
|
||||
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
|
||||
builder.Property(t => t.Result).HasColumnName("result");
|
||||
builder.Property(t => t.LogPath).HasColumnName("log_path");
|
||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(t => t.StartedAt).HasColumnName("started_at");
|
||||
builder.Property(t => t.FinishedAt).HasColumnName("finished_at");
|
||||
builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore");
|
||||
builder.Property(t => t.Model).HasColumnName("model");
|
||||
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
|
||||
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.HasOne(t => t.List)
|
||||
.WithMany(l => l.Tasks)
|
||||
.HasForeignKey(t => t.ListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(t => t.Worktree)
|
||||
.WithOne(w => w.Task)
|
||||
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
|
||||
|
||||
builder.HasMany(t => t.Tags)
|
||||
.WithMany(tag => tag.Tasks)
|
||||
.UsingEntity("task_tags",
|
||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
||||
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
|
||||
j =>
|
||||
{
|
||||
j.HasKey("task_id", "tag_id");
|
||||
j.ToTable("task_tags");
|
||||
});
|
||||
|
||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TaskRunEntity> builder)
|
||||
{
|
||||
builder.ToTable("task_runs");
|
||||
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.Property(r => r.Id).HasColumnName("id");
|
||||
builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired();
|
||||
builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired();
|
||||
builder.Property(r => r.SessionId).HasColumnName("session_id");
|
||||
builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired();
|
||||
builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown");
|
||||
builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output");
|
||||
builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown");
|
||||
builder.Property(r => r.ExitCode).HasColumnName("exit_code");
|
||||
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
|
||||
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
|
||||
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
|
||||
builder.Property(r => r.LogPath).HasColumnName("log_path");
|
||||
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
||||
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
|
||||
|
||||
builder.HasOne(r => r.Task)
|
||||
.WithMany(t => t.Runs)
|
||||
.HasForeignKey(r => r.TaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEntity>
|
||||
{
|
||||
private static string StateToString(WorktreeState v)
|
||||
=> v == WorktreeState.Active ? "active"
|
||||
: v == WorktreeState.Merged ? "merged"
|
||||
: v == WorktreeState.Discarded ? "discarded"
|
||||
: v == WorktreeState.Kept ? "kept"
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static WorktreeState StateFromString(string v)
|
||||
=> v == "active" ? WorktreeState.Active
|
||||
: v == "merged" ? WorktreeState.Merged
|
||||
: v == "discarded" ? WorktreeState.Discarded
|
||||
: v == "kept" ? WorktreeState.Kept
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static readonly ValueConverter<WorktreeState, string> StateConverter =
|
||||
new(v => StateToString(v), v => StateFromString(v));
|
||||
|
||||
public void Configure(EntityTypeBuilder<WorktreeEntity> builder)
|
||||
{
|
||||
builder.ToTable("worktrees");
|
||||
|
||||
builder.HasKey(w => w.TaskId);
|
||||
builder.Property(w => w.TaskId).HasColumnName("task_id");
|
||||
builder.Property(w => w.Path).HasColumnName("path").IsRequired();
|
||||
builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired();
|
||||
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
|
||||
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
|
||||
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
|
||||
builder.Property(w => w.State).HasColumnName("state").IsRequired()
|
||||
.HasDefaultValue(WorktreeState.Active)
|
||||
.HasConversion(StateConverter);
|
||||
builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ public sealed class GitService
|
||||
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)
|
||||
{
|
||||
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}");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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}");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var flag = force ? "-D" : "-d";
|
||||
@@ -104,20 +177,34 @@ public sealed class GitService
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.Start();
|
||||
|
||||
// On cancellation: kill the git process tree. Killing closes the
|
||||
// redirected pipes, which unblocks the ReadToEndAsync calls below
|
||||
// and lets WaitForExitAsync return so the process is reaped.
|
||||
// Without this, cancelling mid-git leaves zombie processes.
|
||||
await using var ctr = ct.Register(() =>
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); }
|
||||
catch { /* already exited */ }
|
||||
});
|
||||
|
||||
if (stdinData is not null)
|
||||
{
|
||||
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
|
||||
proc.StandardInput.Close();
|
||||
}
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
||||
// Drain output without ct — pipes close when the process exits
|
||||
// (whether naturally or via Kill above), so these always complete.
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync();
|
||||
|
||||
await proc.WaitForExitAsync(ct);
|
||||
await proc.WaitForExitAsync(CancellationToken.None);
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal file
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal file
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "lists",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
working_dir = table.Column<string>(type: "TEXT", nullable: true),
|
||||
default_commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_lists", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tags",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tags", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_config",
|
||||
columns: table => new
|
||||
{
|
||||
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
model = table.Column<string>(type: "TEXT", nullable: true),
|
||||
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
|
||||
agent_path = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_config", x => x.list_id);
|
||||
table.ForeignKey(
|
||||
name: "FK_list_config_lists_list_id",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tasks",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
status = table.Column<string>(type: "TEXT", nullable: false),
|
||||
scheduled_for = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
result = table.Column<string>(type: "TEXT", nullable: true),
|
||||
log_path = table.Column<string>(type: "TEXT", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore"),
|
||||
model = table.Column<string>(type: "TEXT", nullable: true),
|
||||
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
|
||||
agent_path = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tasks", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_tasks_lists_list_id",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_tags",
|
||||
columns: table => new
|
||||
{
|
||||
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
|
||||
table.ForeignKey(
|
||||
name: "FK_list_tags_lists_list_id",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_list_tags_tags_tag_id",
|
||||
column: x => x.tag_id,
|
||||
principalTable: "tags",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "subtasks",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
completed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||
order_num = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_subtasks", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_subtasks_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_runs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
run_number = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
session_id = table.Column<string>(type: "TEXT", nullable: true),
|
||||
is_retry = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||
prompt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
result_markdown = table.Column<string>(type: "TEXT", nullable: true),
|
||||
structured_output = table.Column<string>(type: "TEXT", nullable: true),
|
||||
error_markdown = table.Column<string>(type: "TEXT", nullable: true),
|
||||
exit_code = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
turn_count = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
tokens_in = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
tokens_out = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
log_path = table.Column<string>(type: "TEXT", nullable: true),
|
||||
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_runs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_runs_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_tags",
|
||||
columns: table => new
|
||||
{
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
|
||||
table.ForeignKey(
|
||||
name: "FK_task_tags_tags_tag_id",
|
||||
column: x => x.tag_id,
|
||||
principalTable: "tags",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_tags_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "worktrees",
|
||||
columns: table => new
|
||||
{
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
path = table.Column<string>(type: "TEXT", nullable: false),
|
||||
branch_name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
base_commit = table.Column<string>(type: "TEXT", nullable: false),
|
||||
head_commit = table.Column<string>(type: "TEXT", nullable: true),
|
||||
diff_stat = table.Column<string>(type: "TEXT", nullable: true),
|
||||
state = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "active"),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_worktrees", x => x.task_id);
|
||||
table.ForeignKey(
|
||||
name: "FK_worktrees_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "tags",
|
||||
columns: new[] { "id", "name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1L, "agent" },
|
||||
{ 2L, "manual" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_list_tags_tag_id",
|
||||
table: "list_tags",
|
||||
column: "tag_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_subtasks_task_id",
|
||||
table: "subtasks",
|
||||
column: "task_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tags_name",
|
||||
table: "tags",
|
||||
column: "name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_task_runs_task_id",
|
||||
table: "task_runs",
|
||||
column: "task_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_task_tags_tag_id",
|
||||
table: "task_tags",
|
||||
column: "tag_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_list_id",
|
||||
table: "tasks",
|
||||
column: "list_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_status",
|
||||
table: "tasks",
|
||||
column: "status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_config");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "subtasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_runs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "worktrees");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "lists");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
569
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal file
569
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,569 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
partial class ClaudeDoDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
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 =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
Name = "agent"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
Name = "manual"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.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")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.Property<string>("list_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("list_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("list_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.Property<string>("task_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("task_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("task_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("list_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("task_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -6,4 +6,7 @@ public sealed class ListConfigEntity
|
||||
public string? Model { get; set; }
|
||||
public string? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public ListEntity List { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ public sealed class ListEntity
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public string? WorkingDir { get; set; }
|
||||
public string DefaultCommitType { get; set; } = "chore";
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
|
||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
||||
}
|
||||
|
||||
14
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
14
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class SubtaskEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string TaskId { get; init; }
|
||||
public required string Title { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
public int OrderNum { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
|
||||
// Navigation property
|
||||
public TaskEntity Task { get; set; } = null!;
|
||||
}
|
||||
@@ -4,4 +4,8 @@ public sealed class TagEntity
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
|
||||
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
|
||||
}
|
||||
|
||||
@@ -26,4 +26,14 @@ public sealed class TaskEntity
|
||||
public string? Model { get; set; }
|
||||
public string? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
public bool IsStarred { get; set; }
|
||||
public bool IsMyDay { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListEntity List { get; set; } = null!;
|
||||
public WorktreeEntity? Worktree { get; set; }
|
||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
||||
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
||||
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
||||
}
|
||||
|
||||
@@ -18,4 +18,7 @@ public sealed class TaskRunEntity
|
||||
public string? LogPath { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public TaskEntity Task { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -18,4 +18,7 @@ public sealed class WorktreeEntity
|
||||
public string? DiffStat { get; set; }
|
||||
public WorktreeState State { get; set; } = WorktreeState.Active;
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
|
||||
// Navigation property
|
||||
public TaskEntity Task { 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);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +1,91 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class ListRepository
|
||||
{
|
||||
private readonly SqliteConnectionFactory _factory;
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public ListRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||
public ListRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO lists (id, name, created_at, working_dir, default_commit_type)
|
||||
VALUES (@id, @name, @created_at, @working_dir, @default_commit_type)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@id", entity.Id);
|
||||
cmd.Parameters.AddWithValue("@name", entity.Name);
|
||||
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_context.Lists.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE lists SET name = @name, working_dir = @working_dir,
|
||||
default_commit_type = @default_commit_type
|
||||
WHERE id = @id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@id", entity.Id);
|
||||
cmd.Parameters.AddWithValue("@name", entity.Name);
|
||||
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_context.Lists.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM lists WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", listId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", listId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return ReadList(reader);
|
||||
return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
|
||||
}
|
||||
|
||||
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists ORDER BY created_at";
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<ListEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(ReadList(reader));
|
||||
return result;
|
||||
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT t.id, t.name FROM tags t
|
||||
JOIN list_tags lt ON lt.tag_id = t.id
|
||||
WHERE lt.list_id = @list_id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<TagEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
|
||||
return result;
|
||||
return await _context.Lists
|
||||
.Where(l => l.Id == listId)
|
||||
.SelectMany(l => l.Tags)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)";
|
||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
|
||||
if (list is null) return;
|
||||
var tag = await _context.Tags.FindAsync([tagId], ct);
|
||||
if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
|
||||
{
|
||||
list.Tags.Add(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id";
|
||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
|
||||
if (list is null) return;
|
||||
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
|
||||
if (tag is not null)
|
||||
{
|
||||
list.Tags.Remove(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
|
||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
||||
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return new ListConfigEntity
|
||||
public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
ListId = reader.GetString(0),
|
||||
Model = reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
};
|
||||
_context.ListConfigs.Add(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Model = config.Model;
|
||||
existing.SystemPrompt = config.SystemPrompt;
|
||||
existing.AgentPath = config.AgentPath;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
|
||||
VALUES (@list_id, @model, @system_prompt, @agent_path)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@list_id", entity.ListId);
|
||||
cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private static ListEntity ReadList(SqliteDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Name = reader.GetString(1),
|
||||
CreatedAt = DateTime.Parse(reader.GetString(2)),
|
||||
WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
DefaultCommitType = reader.GetString(4),
|
||||
};
|
||||
}
|
||||
|
||||
41
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
41
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class SubtaskRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public SubtaskRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_context.Subtasks.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Subtasks
|
||||
.Where(s => s.TaskId == taskId)
|
||||
.OrderBy(s => s.OrderNum)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_context.Subtasks.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string subtaskId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,28 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class TagRepository
|
||||
{
|
||||
private readonly SqliteConnectionFactory _factory;
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public TagRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||
public TagRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, name FROM tags ORDER BY id";
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<TagEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
|
||||
return result;
|
||||
return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
return await GetOrCreateAsync(conn, name, ct);
|
||||
}
|
||||
|
||||
public static async Task<long> GetOrCreateAsync(SqliteConnection conn, string name, CancellationToken ct = default)
|
||||
{
|
||||
await using var sel = conn.CreateCommand();
|
||||
sel.CommandText = "SELECT id FROM tags WHERE name = @name";
|
||||
sel.Parameters.AddWithValue("@name", name);
|
||||
|
||||
var existing = await sel.ExecuteScalarAsync(ct);
|
||||
var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
||||
if (existing is not null)
|
||||
return (long)existing;
|
||||
return existing.Id;
|
||||
|
||||
await using var ins = conn.CreateCommand();
|
||||
ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id";
|
||||
ins.Parameters.AddWithValue("@name", name);
|
||||
|
||||
return (long)(await ins.ExecuteScalarAsync(ct))!;
|
||||
var tag = new TagEntity { Name = name };
|
||||
_context.Tags.Add(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return tag.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,171 +1,148 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class TaskRepository
|
||||
{
|
||||
private readonly SqliteConnectionFactory _factory;
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public TaskRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||
|
||||
#region Status mapping
|
||||
|
||||
private static string ToDb(TaskStatus s) => s switch
|
||||
{
|
||||
TaskStatus.Manual => "manual",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.Done => "done",
|
||||
TaskStatus.Failed => "failed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
private static TaskStatus FromDb(string s) => s switch
|
||||
{
|
||||
"manual" => TaskStatus.Manual,
|
||||
"queued" => TaskStatus.Queued,
|
||||
"running" => TaskStatus.Running,
|
||||
"done" => TaskStatus.Done,
|
||||
"failed" => TaskStatus.Failed,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
#endregion
|
||||
public TaskRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
#region CRUD
|
||||
|
||||
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
|
||||
result, log_path, created_at, started_at, finished_at, commit_type,
|
||||
model, system_prompt, agent_path)
|
||||
VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
|
||||
@result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
|
||||
@model, @system_prompt, @agent_path)
|
||||
""";
|
||||
BindTask(cmd, entity);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_context.Tasks.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
|
||||
status = @status, scheduled_for = @scheduled_for, result = @result,
|
||||
log_path = @log_path, started_at = @started_at,
|
||||
finished_at = @finished_at, commit_type = @commit_type,
|
||||
model = @model, system_prompt = @system_prompt, agent_path = @agent_path
|
||||
WHERE id = @id
|
||||
""";
|
||||
BindTask(cmd, entity);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_context.Tasks.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM tasks WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", taskId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return ReadTask(reader);
|
||||
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
}
|
||||
|
||||
public async Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE list_id = @list_id ORDER BY created_at";
|
||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
||||
return await _context.Tasks
|
||||
.Where(t => t.ListId == listId)
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<TaskEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(ReadTask(reader));
|
||||
return result;
|
||||
// Kept for backwards-compatibility with callers using the old name.
|
||||
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||
=> GetByListIdAsync(listId, ct);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status transitions
|
||||
|
||||
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Running)
|
||||
.SetProperty(t => t.StartedAt, startedAt), ct);
|
||||
}
|
||||
|
||||
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Done)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt)
|
||||
.SetProperty(t => t.Result, result), ct);
|
||||
}
|
||||
|
||||
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Failed)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt)
|
||||
.SetProperty(t => t.Result, result), ct);
|
||||
}
|
||||
|
||||
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
|
||||
}
|
||||
|
||||
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
|
||||
{
|
||||
var resultText = "[stale] " + reason;
|
||||
var now = DateTime.UtcNow;
|
||||
return await _context.Tasks
|
||||
.Where(t => t.Status == TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Failed)
|
||||
.SetProperty(t => t.FinishedAt, now)
|
||||
.SetProperty(t => t.Result, resultText), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tag junction
|
||||
|
||||
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT t.id, t.name FROM tags t
|
||||
JOIN task_tags tt ON tt.tag_id = t.id
|
||||
WHERE tt.task_id = @task_id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<TagEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
|
||||
return result;
|
||||
}
|
||||
#region Tags
|
||||
|
||||
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
if (task is null) return;
|
||||
var tag = await _context.Tags.FindAsync([tagId], ct);
|
||||
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
|
||||
{
|
||||
task.Tags.Add(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
if (task is null) return;
|
||||
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
|
||||
if (tag is not null)
|
||||
{
|
||||
task.Tags.Remove(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.Tags)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT DISTINCT t.id, t.name FROM tags t
|
||||
WHERE t.id IN (
|
||||
SELECT tag_id FROM task_tags WHERE task_id = @task_id
|
||||
UNION
|
||||
SELECT lt.tag_id FROM list_tags lt
|
||||
JOIN tasks tk ON tk.list_id = lt.list_id
|
||||
WHERE tk.id = @task_id
|
||||
)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<TagEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
|
||||
return result;
|
||||
var taskTags = _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.Tags);
|
||||
var listTags = _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.SelectMany(t => t.List.Tags);
|
||||
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -174,136 +151,38 @@ public sealed class TaskRepository
|
||||
|
||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for,
|
||||
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type,
|
||||
t.model, t.system_prompt, t.agent_path
|
||||
FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM task_tags tt
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
||||
UNION
|
||||
SELECT 1 FROM list_tags lt
|
||||
JOIN tags tg ON tg.id = lt.tag_id
|
||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||
)
|
||||
ORDER BY t.created_at ASC
|
||||
LIMIT 1
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
|
||||
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
|
||||
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
|
||||
// automatically enqueue all their tasks without per-task tagging.
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
|
||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
var result = await _context.Tasks.FromSqlRaw("""
|
||||
UPDATE tasks SET status = 'running'
|
||||
WHERE id = (
|
||||
SELECT t.id FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM task_tags tt
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM list_tags lt
|
||||
JOIN tags tg ON tg.id = lt.tag_id
|
||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||
)
|
||||
)
|
||||
ORDER BY t.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *
|
||||
""", nowStr).ToListAsync(ct);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return ReadTask(reader);
|
||||
return result.FirstOrDefault();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Transitions
|
||||
|
||||
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE tasks SET log_path = @log_path WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", taskId);
|
||||
cmd.Parameters.AddWithValue("@log_path", logPath);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE tasks SET status = 'running', started_at = @started_at WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", taskId);
|
||||
cmd.Parameters.AddWithValue("@started_at", startedAt.ToString("o"));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE tasks SET status = 'done', finished_at = @finished_at, result = @result WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", taskId);
|
||||
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("@result", (object?)result ?? DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? errorMarkdown, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE tasks SET status = 'failed', finished_at = @finished_at, result = @result WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", taskId);
|
||||
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("@result", (object?)errorMarkdown ?? DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE tasks SET status = 'failed',
|
||||
finished_at = @now,
|
||||
result = '[stale] ' || @reason
|
||||
WHERE status = 'running'
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("@reason", reason);
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static void BindTask(SqliteCommand cmd, TaskEntity e)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@id", e.Id);
|
||||
cmd.Parameters.AddWithValue("@list_id", e.ListId);
|
||||
cmd.Parameters.AddWithValue("@title", e.Title);
|
||||
cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
|
||||
cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
|
||||
cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
|
||||
}
|
||||
|
||||
private static TaskEntity ReadTask(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
ListId = r.GetString(1),
|
||||
Title = r.GetString(2),
|
||||
Description = r.IsDBNull(3) ? null : r.GetString(3),
|
||||
Status = FromDb(r.GetString(4)),
|
||||
ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
|
||||
Result = r.IsDBNull(6) ? null : r.GetString(6),
|
||||
LogPath = r.IsDBNull(7) ? null : r.GetString(7),
|
||||
CreatedAt = DateTime.Parse(r.GetString(8)),
|
||||
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
|
||||
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
|
||||
CommitType = r.GetString(11),
|
||||
Model = r.IsDBNull(12) ? null : r.GetString(12),
|
||||
SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
|
||||
AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,139 +1,44 @@
|
||||
using System.Globalization;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class TaskRunRepository
|
||||
{
|
||||
private readonly SqliteConnectionFactory _factory;
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||
public TaskRunRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
|
||||
result_markdown, structured_output, error_markdown, exit_code,
|
||||
turn_count, tokens_in, tokens_out, log_path, started_at, finished_at)
|
||||
VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
|
||||
@result_markdown, @structured_output, @error_markdown, @exit_code,
|
||||
@turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at)
|
||||
""";
|
||||
BindRun(cmd, entity);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_context.TaskRuns.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE task_runs SET session_id = @session_id,
|
||||
result_markdown = @result_markdown,
|
||||
structured_output = @structured_output,
|
||||
error_markdown = @error_markdown,
|
||||
exit_code = @exit_code,
|
||||
turn_count = @turn_count,
|
||||
tokens_in = @tokens_in,
|
||||
tokens_out = @tokens_out,
|
||||
finished_at = @finished_at
|
||||
WHERE id = @id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@id", entity.Id);
|
||||
cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_context.TaskRuns.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<TaskRunEntity?> GetByIdAsync(string runId, CancellationToken ct = default)
|
||||
public async Task<TaskRunEntity?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", runId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return ReadRun(reader);
|
||||
return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var result = new List<TaskRunEntity>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
result.Add(ReadRun(reader));
|
||||
return result;
|
||||
return await _context.TaskRuns
|
||||
.Where(r => r.TaskId == taskId)
|
||||
.OrderBy(r => r.RunNumber)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return ReadRun(reader);
|
||||
return await _context.TaskRuns
|
||||
.Where(r => r.TaskId == taskId)
|
||||
.OrderByDescending(r => r.RunNumber)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@id", e.Id);
|
||||
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
|
||||
cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
|
||||
cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@prompt", e.Prompt);
|
||||
cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
|
||||
}
|
||||
|
||||
private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
TaskId = r.GetString(1),
|
||||
RunNumber = r.GetInt32(2),
|
||||
SessionId = r.IsDBNull(3) ? null : r.GetString(3),
|
||||
IsRetry = r.GetInt32(4) != 0,
|
||||
Prompt = r.GetString(5),
|
||||
ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
|
||||
StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
|
||||
ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
|
||||
ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
|
||||
TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
|
||||
TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
|
||||
TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
|
||||
LogPath = r.IsDBNull(13) ? null : r.GetString(13),
|
||||
StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14), null, DateTimeStyles.RoundtripKind),
|
||||
FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15), null, DateTimeStyles.RoundtripKind),
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,102 +1,56 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class WorktreeRepository
|
||||
{
|
||||
private readonly SqliteConnectionFactory _factory;
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||
|
||||
private static string ToDb(WorktreeState s) => s switch
|
||||
{
|
||||
WorktreeState.Active => "active",
|
||||
WorktreeState.Merged => "merged",
|
||||
WorktreeState.Discarded => "discarded",
|
||||
WorktreeState.Kept => "kept",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
private static WorktreeState FromDb(string s) => s switch
|
||||
{
|
||||
"active" => WorktreeState.Active,
|
||||
"merged" => WorktreeState.Merged,
|
||||
"discarded" => WorktreeState.Discarded,
|
||||
"kept" => WorktreeState.Kept,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
public WorktreeRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO worktrees (task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at)
|
||||
VALUES (@task_id, @path, @branch_name, @base_commit, @head_commit, @diff_stat, @state, @created_at)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@task_id", entity.TaskId);
|
||||
cmd.Parameters.AddWithValue("@path", entity.Path);
|
||||
cmd.Parameters.AddWithValue("@branch_name", entity.BranchName);
|
||||
cmd.Parameters.AddWithValue("@base_commit", entity.BaseCommit);
|
||||
cmd.Parameters.AddWithValue("@head_commit", (object?)entity.HeadCommit ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@diff_stat", (object?)entity.DiffStat ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@state", ToDb(entity.State));
|
||||
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_context.Worktrees.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at FROM worktrees WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return ReadWorktree(reader);
|
||||
return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct);
|
||||
}
|
||||
|
||||
public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
cmd.Parameters.AddWithValue("@head_commit", headCommit);
|
||||
cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
await _context.Worktrees
|
||||
.Where(w => w.TaskId == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(w => w.HeadCommit, headCommit)
|
||||
.SetProperty(w => w.DiffStat, diffStat), ct);
|
||||
}
|
||||
|
||||
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
cmd.Parameters.AddWithValue("@state", ToDb(state));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
await _context.Worktrees
|
||||
.Where(w => w.TaskId == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM worktrees WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static WorktreeEntity ReadWorktree(SqliteDataReader r) => new()
|
||||
public async Task<List<WorktreeEntity>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
TaskId = r.GetString(0),
|
||||
Path = r.GetString(1),
|
||||
BranchName = r.GetString(2),
|
||||
BaseCommit = r.GetString(3),
|
||||
HeadCommit = r.IsDBNull(4) ? null : r.GetString(4),
|
||||
DiffStat = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
State = FromDb(r.GetString(6)),
|
||||
CreatedAt = DateTime.Parse(r.GetString(7)),
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
|
||||
/// IF NOT EXISTS / INSERT OR IGNORE.
|
||||
/// </summary>
|
||||
public static class SchemaInitializer
|
||||
{
|
||||
private const string ResourceName = "ClaudeDo.Data.schema.sql";
|
||||
|
||||
public static void Apply(SqliteConnectionFactory factory)
|
||||
{
|
||||
using var conn = factory.Open();
|
||||
ApplyTo(conn);
|
||||
}
|
||||
|
||||
public static void ApplyTo(SqliteConnection conn)
|
||||
{
|
||||
var sql = LoadScript();
|
||||
using var tx = conn.BeginTransaction();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
tx.Commit();
|
||||
|
||||
ApplyMigrations(conn);
|
||||
}
|
||||
|
||||
private static void ApplyMigrations(SqliteConnection conn)
|
||||
{
|
||||
string[] alterStatements =
|
||||
[
|
||||
"ALTER TABLE tasks ADD COLUMN model TEXT NULL",
|
||||
"ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
|
||||
"ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
|
||||
];
|
||||
|
||||
foreach (var sql in alterStatements)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 1)
|
||||
{
|
||||
// Column already exists — safe to ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadScript()
|
||||
{
|
||||
var asm = typeof(SchemaInitializer).Assembly;
|
||||
using var stream = asm.GetManifestResourceStream(ResourceName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " +
|
||||
$"Available: {string.Join(", ", asm.GetManifestResourceNames())}");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
|
||||
/// First call ensures the parent directory exists, enables WAL and foreign keys.
|
||||
/// </summary>
|
||||
public sealed class SqliteConnectionFactory
|
||||
{
|
||||
public string DbPath { get; }
|
||||
private readonly string _connectionString;
|
||||
private int _walApplied;
|
||||
|
||||
public SqliteConnectionFactory(string dbPath)
|
||||
{
|
||||
DbPath = Paths.Expand(dbPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
|
||||
|
||||
_connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = DbPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
}.ToString();
|
||||
}
|
||||
|
||||
public SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
|
||||
// WAL is a persistent DB-level setting; applying it once per process is enough,
|
||||
// but idempotent so we do it defensively on the first connection we hand out.
|
||||
if (Interlocked.Exchange(ref _walApplied, 1) == 0)
|
||||
{
|
||||
using var pragma = conn.CreateCommand();
|
||||
pragma.CommandText = "PRAGMA journal_mode=WAL;";
|
||||
pragma.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var fk = conn.CreateCommand();
|
||||
fk.CommandText = "PRAGMA foreign_keys=ON;";
|
||||
fk.ExecuteNonQuery();
|
||||
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -110,13 +110,16 @@ public partial class App : Application
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<WriteInstallManifestStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||
|
||||
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||
// Pulled by Update flow + Repair/Uninstall.
|
||||
sc.AddSingleton<StopServiceStep>();
|
||||
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
|
||||
sc.AddSingleton<StartServiceStep>();
|
||||
|
||||
// Runners
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationIcon>ClaudeTaskSetup.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embed icon so it is available via pack URI in WPF windows. -->
|
||||
<Resource Include="ClaudeTaskSetup.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
|
||||
@@ -19,10 +27,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
|
||||
<!-- Framework-dependent: the WPF runtime pack isn't distributed for cross-compile
|
||||
on Linux CI, which made self-contained bundles crash on startup with AV in the
|
||||
apphost. Target machines already have the .NET 8 Desktop Runtime. -->
|
||||
<SelfContained>false</SelfContained>
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 B |
@@ -22,7 +22,7 @@ public sealed class InstallContext
|
||||
public int SignalRPort { get; set; } = 47_821;
|
||||
public int QueueBackstopIntervalMs { get; set; } = 30_000;
|
||||
public string ClaudeBin { get; set; } = "claude";
|
||||
public string ServiceAccount { get; set; } = "LocalSystem";
|
||||
public string ServiceAccount { get; set; } = "CurrentUser";
|
||||
public bool AutoStart { get; set; } = true;
|
||||
public int RestartDelayMs { get; set; } = 5000;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
@@ -15,7 +17,7 @@ public sealed class UninstallRunner
|
||||
_stopService = stopService;
|
||||
}
|
||||
|
||||
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
|
||||
public async Task<StepResult> RunAsync(bool removeAppData, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
// 1) Validate install dir up front — refuse obviously unsafe paths.
|
||||
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
|
||||
@@ -36,6 +38,17 @@ public sealed class UninstallRunner
|
||||
progress.Report("Unregistering service...");
|
||||
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
|
||||
|
||||
// 3b) Remove Apps & Features registry entry (best-effort).
|
||||
progress.Report("Removing Add/Remove Programs entry...");
|
||||
try
|
||||
{
|
||||
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
|
||||
}
|
||||
|
||||
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
|
||||
progress.Report("Removing shortcuts...");
|
||||
TryDeleteFile(Path.Combine(
|
||||
@@ -54,13 +67,31 @@ public sealed class UninstallRunner
|
||||
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
||||
}
|
||||
|
||||
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
|
||||
var appData = Paths.AppDataRoot();
|
||||
if (Directory.Exists(appData))
|
||||
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
|
||||
if (removeAppData)
|
||||
{
|
||||
progress.Report($"Deleting {appData}...");
|
||||
if (!TryDeleteDir(appData, out var err))
|
||||
failures.Add($"app data ({appData}): {err}");
|
||||
var appData = Paths.AppDataRoot();
|
||||
if (Directory.Exists(appData))
|
||||
{
|
||||
progress.Report($"Deleting {appData}...");
|
||||
if (!TryDeleteDir(appData, out var err))
|
||||
failures.Add($"app data ({appData}): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
// 7) If we were launched from inside the install dir (Apps & Features case),
|
||||
// our own exe is still locked — schedule a cmd.exe trampoline to finish
|
||||
// the deletion after this process exits. Best-effort: if this fails the
|
||||
// user is left with an empty <uninstaller> folder which is harmless.
|
||||
var runningExe = Environment.ProcessPath;
|
||||
if (runningExe is not null
|
||||
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
|
||||
&& Directory.Exists(_context.InstallDirectory))
|
||||
{
|
||||
progress.Report("Scheduling final cleanup after exit...");
|
||||
TryScheduleTrampolineDelete(_context.InstallDirectory);
|
||||
// The trampoline will finish the job — clear the residual failure entry for the install dir.
|
||||
failures.RemoveAll(f => f.StartsWith("install dir"));
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
@@ -74,6 +105,37 @@ public sealed class UninstallRunner
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static bool IsInsideDirectory(string filePath, string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(filePath);
|
||||
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static void TryScheduleTrampolineDelete(string installDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pid = Environment.ProcessId;
|
||||
// Wait for this process to exit, then recursively remove the install dir.
|
||||
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
|
||||
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = cmd,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
});
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guards against catastrophic recursive-delete paths. The install dir must be
|
||||
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
|
||||
|
||||
@@ -54,6 +54,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
Steps.Add(new StepViewModel("Initialize Database"));
|
||||
Steps.Add(new StepViewModel("Register Windows Service"));
|
||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
@@ -82,7 +83,21 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
|
||||
step.Status = p.Status;
|
||||
if (p.Message is not null)
|
||||
step.Messages.Add(p.Message);
|
||||
{
|
||||
// Messages starting with "\r" overwrite the previous line (live progress).
|
||||
if (p.Message.StartsWith('\r'))
|
||||
{
|
||||
var line = p.Message[1..];
|
||||
if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" "))
|
||||
step.Messages[^1] = line;
|
||||
else
|
||||
step.Messages.Add(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
step.Messages.Add(p.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (p.Status is StepStatus.Running && !step.IsExpanded)
|
||||
step.IsExpanded = true;
|
||||
|
||||
@@ -44,9 +44,18 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
|
||||
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
|
||||
|
||||
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
|
||||
var totalMb = zipAsset.Size / (1024 * 1024);
|
||||
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
|
||||
long lastReportedMb = -1;
|
||||
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
|
||||
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
|
||||
new Progress<long>(b =>
|
||||
{
|
||||
var mb = b / (1024 * 1024);
|
||||
if (mb == lastReportedMb) return;
|
||||
lastReportedMb = mb;
|
||||
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
|
||||
progress.Report($"\r {mb} / {totalMb} MB downloaded");
|
||||
}),
|
||||
ct);
|
||||
|
||||
progress.Report("Downloading checksums...");
|
||||
@@ -61,11 +70,16 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
|
||||
|
||||
// Only after verification do we touch the install directory.
|
||||
progress.Report("Clearing previous app/worker binaries...");
|
||||
progress.Report("Stashing previous app/worker binaries...");
|
||||
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
||||
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
||||
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
||||
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
||||
var appBak = appDest + ".bak";
|
||||
var workerBak = workerDest + ".bak";
|
||||
|
||||
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||
if (Directory.Exists(appDest)) Directory.Move(appDest, appBak);
|
||||
if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak);
|
||||
|
||||
progress.Report("Extracting...");
|
||||
Directory.CreateDirectory(ctx.InstallDirectory);
|
||||
@@ -75,11 +89,19 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Roll back to previous binaries.
|
||||
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
||||
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
||||
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest);
|
||||
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest);
|
||||
return StepResult.Fail(
|
||||
$"Extraction failed after old binaries were removed: {ex.Message}. " +
|
||||
"Your install directory may be incomplete. Re-run the installer to retry.");
|
||||
$"Extraction failed; previous binaries have been restored: {ex.Message}.");
|
||||
}
|
||||
|
||||
// Success — drop stash.
|
||||
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||
|
||||
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
@@ -14,8 +15,11 @@ public sealed class InitDatabaseStep : IInstallStep
|
||||
var expandedPath = Paths.Expand(ctx.DbPath);
|
||||
progress.Report($"Initializing database at {expandedPath}");
|
||||
|
||||
var factory = new SqliteConnectionFactory(expandedPath);
|
||||
SchemaInitializer.Apply(factory);
|
||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={expandedPath}")
|
||||
.Options;
|
||||
using var context = new ClaudeDoDbContext(options);
|
||||
ClaudeDoDbContext.MigrateAndConfigure(context);
|
||||
|
||||
progress.Report("Schema applied successfully");
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
|
||||
@@ -23,18 +23,39 @@ public sealed class RegisterServiceStep : IInstallStep
|
||||
progress.Report("Removing existing service registration (if any)...");
|
||||
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
|
||||
// Wait for the service to actually disappear from SCM. `sc delete` returns
|
||||
// immediately but the service stays "marked for deletion" until every open
|
||||
// handle (services.msc, Task Manager, a prior sc query process) is closed.
|
||||
// Poll up to 30s — then fail with actionable guidance if it's still there.
|
||||
progress.Report("Waiting for prior service registration to clear...");
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
if (queryExit != 0) break; // service no longer registered — good
|
||||
if (i == 29)
|
||||
return StepResult.Fail(
|
||||
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
|
||||
"Close any open Services console (services.msc), Task Manager Services tab, or " +
|
||||
"Event Viewer showing the service, then retry. A reboot will also clear it.");
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
|
||||
// Create service
|
||||
var startType = ctx.AutoStart ? "auto" : "demand";
|
||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
||||
|
||||
if (ctx.ServiceAccount == "CurrentUser")
|
||||
{
|
||||
var username = Environment.UserName;
|
||||
createArgs += $" obj= \".\\{username}\"";
|
||||
}
|
||||
return StepResult.Fail(
|
||||
"Service cannot run as Current User without a password. " +
|
||||
"Select 'Local System' or extend ServicePage to capture a password.");
|
||||
|
||||
progress.Report("Creating service...");
|
||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
||||
if (exitCode == 1072)
|
||||
return StepResult.Fail(
|
||||
$"Service '{ServiceName}' is still marked for deletion. " +
|
||||
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
|
||||
if (exitCode != 0)
|
||||
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
|
||||
|
||||
@@ -46,15 +67,6 @@ public sealed class RegisterServiceStep : IInstallStep
|
||||
if (failExit != 0)
|
||||
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
|
||||
|
||||
// Start service if auto-start
|
||||
if (ctx.AutoStart)
|
||||
{
|
||||
progress.Report("Starting service...");
|
||||
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
|
||||
if (startExit != 0)
|
||||
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
|
||||
}
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ public sealed class StopServiceStep : IInstallStep
|
||||
}
|
||||
|
||||
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
|
||||
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
|
||||
if (stopExit == 1062)
|
||||
{
|
||||
progress.Report("Service was registered but not running.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
if (stopExit != 0)
|
||||
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
@@ -10,13 +11,15 @@ public sealed class WriteConfigStep : IInstallStep
|
||||
{
|
||||
try
|
||||
{
|
||||
// Expand ~ to the installing user's absolute path so the worker
|
||||
// service always finds the correct DB regardless of service account.
|
||||
var workerCfg = new InstallerWorkerConfig
|
||||
{
|
||||
DbPath = ctx.DbPath,
|
||||
SandboxRoot = ctx.SandboxRoot,
|
||||
LogRoot = ctx.LogRoot,
|
||||
DbPath = Paths.Expand(ctx.DbPath),
|
||||
SandboxRoot = Paths.Expand(ctx.SandboxRoot),
|
||||
LogRoot = Paths.Expand(ctx.LogRoot),
|
||||
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
|
||||
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
|
||||
CentralWorktreeRoot = Paths.Expand(ctx.CentralWorktreeRoot),
|
||||
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
|
||||
SignalRPort = ctx.SignalRPort,
|
||||
ClaudeBin = ctx.ClaudeBin,
|
||||
@@ -26,7 +29,7 @@ public sealed class WriteConfigStep : IInstallStep
|
||||
|
||||
var uiCfg = new InstallerAppSettings
|
||||
{
|
||||
DbPath = ctx.UiDbPath,
|
||||
DbPath = Paths.Expand(ctx.UiDbPath),
|
||||
SignalRUrl = ctx.SignalRUrl,
|
||||
};
|
||||
uiCfg.Save();
|
||||
|
||||
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal file
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
|
||||
/// so it shows up in Windows "Apps & Features" / "Programs and Features".
|
||||
/// Also copies the running installer into the install directory so there is an exe
|
||||
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
|
||||
/// </summary>
|
||||
public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||
{
|
||||
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
|
||||
|
||||
public string Name => "Register in Add/Remove Programs";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
|
||||
Directory.CreateDirectory(uninstallDir);
|
||||
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
|
||||
|
||||
// Copy the running installer so Apps & Features has a stable exe to launch —
|
||||
// the single-file temp extract is gone once this process exits.
|
||||
var sourceExe = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||
try
|
||||
{
|
||||
progress.Report("Copying uninstaller binary...");
|
||||
File.Copy(sourceExe, targetExe, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
|
||||
}
|
||||
|
||||
progress.Report("Writing Add/Remove Programs entry...");
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
|
||||
if (key is null)
|
||||
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
|
||||
|
||||
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
|
||||
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
|
||||
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
|
||||
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
|
||||
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
|
||||
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
|
||||
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
||||
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||
|
||||
// Best-effort install size (KB) — scan install dir.
|
||||
try
|
||||
{
|
||||
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
|
||||
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
|
||||
}
|
||||
catch { /* best-effort only */ }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static long DirectorySizeBytes(string path)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,34 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ComboBox toggle button (dropdown arrow chrome) -->
|
||||
<ControlTemplate x:Key="ComboBoxToggleButtonTemplate" TargetType="ToggleButton">
|
||||
<Border x:Name="Bd"
|
||||
Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="20"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Path Grid.Column="1"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Fill="{StaticResource TextSecondaryBrush}"
|
||||
Data="M 0 0 L 4 4 L 8 0 Z"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
||||
<!-- ComboBox -->
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
@@ -191,6 +219,71 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton x:Name="ToggleButton"
|
||||
Template="{StaticResource ComboBoxToggleButtonTemplate}"
|
||||
Focusable="False"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
ClickMode="Press"/>
|
||||
<ContentPresenter x:Name="ContentSite"
|
||||
IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<Popup x:Name="Popup"
|
||||
Placement="Bottom"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True" Focusable="False"
|
||||
PopupAnimation="Slide">
|
||||
<Border x:Name="DropDownBorder"
|
||||
Background="{StaticResource IslandBgBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
MinWidth="{TemplateBinding ActualWidth}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<ScrollViewer SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ComboBoxItem — dark dropdown rows -->
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- CheckBox -->
|
||||
|
||||
@@ -29,6 +29,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string _versionLabel = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _removeAppData;
|
||||
|
||||
public SettingsViewModel(
|
||||
PageResolver resolver,
|
||||
InstallContext context,
|
||||
@@ -133,8 +136,12 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private async Task Uninstall()
|
||||
{
|
||||
var dataNote = RemoveAppData
|
||||
? "This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?"
|
||||
: "This will remove ClaudeDo. Your tasks, configuration, and database in ~/.todo-app will be kept.\n\nContinue?";
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
|
||||
dataNote,
|
||||
"Uninstall ClaudeDo",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning);
|
||||
@@ -142,7 +149,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
|
||||
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
|
||||
var r = await _uninstallRunner.RunAsync(RemoveAppData, progress, CancellationToken.None);
|
||||
|
||||
if (!r.Success)
|
||||
{
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
||||
Title="ClaudeDo Settings"
|
||||
Icon="/ClaudeTaskSetup.ico"
|
||||
Width="720" Height="520"
|
||||
MinWidth="620" MinHeight="460"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource WindowBgBrush}"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontFamily="Segoe UI"
|
||||
FontSize="13"
|
||||
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
@@ -64,6 +69,7 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Status message / version label -->
|
||||
@@ -83,14 +89,17 @@
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
|
||||
<CheckBox Grid.Column="1" IsChecked="{Binding RemoveAppData}"
|
||||
Content="Remove user data (tasks, logs, configs in ~/.todo-app)"
|
||||
Margin="0,0,12,0" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Content="Uninstall" Margin="0,0,8,0"
|
||||
Command="{Binding UninstallCommand}"/>
|
||||
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
|
||||
<Button Grid.Column="3" Content="Repair" Margin="0,0,8,0"
|
||||
Command="{Binding RepairCommand}"/>
|
||||
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
|
||||
<Button Grid.Column="4" Content="Save" Margin="0,0,8,0"
|
||||
Command="{Binding SaveCommand}"
|
||||
Style="{StaticResource AccentButton}"/>
|
||||
<Button Grid.Column="4" Content="Close"
|
||||
<Button Grid.Column="5" Content="Close"
|
||||
Command="{Binding CloseCommand}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
||||
Title="ClaudeDo Installer"
|
||||
Icon="/ClaudeTaskSetup.ico"
|
||||
Width="720" Height="520"
|
||||
MinWidth="620" MinHeight="460"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource WindowBgBrush}"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontFamily="Segoe UI"
|
||||
FontSize="13"
|
||||
d:DataContext="{d:DesignInstance views:WizardViewModel}"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
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.
|
||||
@@ -18,4 +18,10 @@
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
|
||||
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
|
||||
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
|
||||
</ItemGroup>
|
||||
|
||||
</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();
|
||||
}
|
||||
831
src/ClaudeDo.Ui/Design/IslandStyles.axaml
Normal file
831
src/ClaudeDo.Ui/Design/IslandStyles.axaml
Normal file
@@ -0,0 +1,831 @@
|
||||
<!--
|
||||
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}" />
|
||||
</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}" />
|
||||
</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>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 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>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 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="6,2" />
|
||||
</Style>
|
||||
<Style Selector="Border.kbd > TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||
</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
|
||||
{
|
||||
private const int MaxLength = 50_000;
|
||||
private const int MaxArgChars = 120;
|
||||
|
||||
public string? FormatLine(string line)
|
||||
{
|
||||
@@ -22,75 +23,295 @@ public class StreamLineFormatter
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
if (!root.TryGetProperty("type", out var typeProp))
|
||||
return null;
|
||||
|
||||
var type = typeProp.GetString();
|
||||
|
||||
switch (type)
|
||||
return typeProp.GetString() switch
|
||||
{
|
||||
case "stream_event":
|
||||
return FormatStreamEvent(root);
|
||||
|
||||
case "result":
|
||||
if (root.TryGetProperty("result", out var resultProp))
|
||||
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;
|
||||
}
|
||||
"system" => FormatSystem(root),
|
||||
"assistant" => FormatAssistant(root),
|
||||
"user" => FormatUser(root),
|
||||
"result" => FormatResult(root),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FormatStreamEvent(JsonElement root)
|
||||
private static string? FormatSystem(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("event", out var ev))
|
||||
return null;
|
||||
if (!ev.TryGetProperty("type", out var evTypeProp))
|
||||
if (!root.TryGetProperty("subtype", out var subtypeProp))
|
||||
return null;
|
||||
|
||||
var evType = evTypeProp.GetString();
|
||||
|
||||
switch (evType)
|
||||
var subtype = subtypeProp.GetString();
|
||||
switch (subtype)
|
||||
{
|
||||
case "content_block_delta":
|
||||
if (!ev.TryGetProperty("delta", out var delta))
|
||||
return null;
|
||||
if (!delta.TryGetProperty("type", out var deltaTypeProp))
|
||||
return null;
|
||||
var deltaType = deltaTypeProp.GetString();
|
||||
if (deltaType == "text_delta")
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
? textProp.GetString()
|
||||
: null;
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append(t.GetString());
|
||||
}
|
||||
return null; // input_json_delta and others → skip
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
return "\n";
|
||||
return "";
|
||||
}
|
||||
|
||||
case "content_block_start":
|
||||
if (!ev.TryGetProperty("content_block", out var cb))
|
||||
return null;
|
||||
if (cb.TryGetProperty("type", out var cbTypeProp) &&
|
||||
cbTypeProp.GetString() == "tool_use" &&
|
||||
cb.TryGetProperty("name", out var nameProp))
|
||||
return $"\n[Tool: {nameProp.GetString()}]\n";
|
||||
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 "";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
@@ -208,9 +209,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt));
|
||||
});
|
||||
}
|
||||
catch
|
||||
catch (HubException)
|
||||
{
|
||||
// Worker might not support GetActive yet
|
||||
// Expected: worker doesn't support GetActive yet
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"SeedActiveTasksAsync failed: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +226,47 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
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<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
|
||||
private sealed class ActiveTaskDto
|
||||
{
|
||||
@@ -229,3 +275,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
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);
|
||||
|
||||
401
src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
Normal file
401
src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
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";
|
||||
|
||||
partial void OnAgentStatusLabelChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
}
|
||||
[ObservableProperty] private string? _model;
|
||||
[ObservableProperty] private string? _worktreePath;
|
||||
[ObservableProperty] private string? _worktreeBaseCommit;
|
||||
[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();
|
||||
|
||||
// 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 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 RunNow CanExecute when worker connection flips.
|
||||
_worker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||
RunNowCommand.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 });
|
||||
}
|
||||
|
||||
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;
|
||||
BranchLine = null;
|
||||
AgentStatusLabel = "Idle";
|
||||
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;
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
|
||||
// Subscribe only after DB load confirms the task exists
|
||||
_subscribedTaskId = row.Id;
|
||||
|
||||
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 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;
|
||||
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,
|
||||
};
|
||||
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();
|
||||
}
|
||||
|
||||
[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 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]
|
||||
private async System.Threading.Tasks.Task ApproveMergeAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
// TODO: call worker merge hub method when available
|
||||
await System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private bool _done;
|
||||
}
|
||||
14
src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs
Normal file
14
src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
public string? IconKey { get; init; }
|
||||
public string? DotColorKey { get; init; }
|
||||
}
|
||||
112
src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
Normal file
112
src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
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;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
public event EventHandler? FocusSearchRequested;
|
||||
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { 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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_services = services;
|
||||
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();
|
||||
}
|
||||
|
||||
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: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],
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
idx++;
|
||||
}
|
||||
|
||||
await RefreshCountsAsync(ct);
|
||||
SelectedList = Items.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
||||
{
|
||||
foreach (var i in Items) i.Count = 0;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
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",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user