docs: add UI-rewrite notes, plans, and stream-formatter spec

This commit is contained in:
Mika Kuns
2026-04-21 15:56:19 +02:00
parent a180e8446c
commit 23f8fddc4d
16 changed files with 7165 additions and 0 deletions

File diff suppressed because one or more lines are too long

View 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>

View 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>

View 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.

View 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>

View 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 />);

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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 (W1W3) 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`.

View 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.

File diff suppressed because it is too large Load Diff

View 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).

View File

@@ -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 24 cap output at a
single line; full content stays in the raw log.